@iobroker/mcp-server 0.0.2
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/LICENSE +21 -0
- package/README.md +163 -0
- package/build/devices.d.ts +127 -0
- package/build/devices.js +752 -0
- package/build/devices.js.map +1 -0
- package/build/inProcessClient.d.ts +49 -0
- package/build/inProcessClient.js +76 -0
- package/build/inProcessClient.js.map +1 -0
- package/build/index.d.ts +202 -0
- package/build/index.js +1807 -0
- package/build/index.js.map +1 -0
- package/build/iob-uri.d.ts +20 -0
- package/build/iob-uri.js +108 -0
- package/build/iob-uri.js.map +1 -0
- package/build/types.d.ts +17 -0
- package/build/types.js +3 -0
- package/build/types.js.map +1 -0
- package/package.json +73 -0
package/build/index.js
ADDED
|
@@ -0,0 +1,1807 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.createInProcessMcp = exports.McpServer = void 0;
|
|
7
|
+
const express_1 = __importDefault(require("express"));
|
|
8
|
+
const node_crypto_1 = require("node:crypto");
|
|
9
|
+
const node_os_1 = __importDefault(require("node:os"));
|
|
10
|
+
const node_net_1 = __importDefault(require("node:net"));
|
|
11
|
+
const node_dns_1 = __importDefault(require("node:dns"));
|
|
12
|
+
const node_fs_1 = __importDefault(require("node:fs"));
|
|
13
|
+
const node_child_process_1 = require("node:child_process");
|
|
14
|
+
const zod_1 = require("zod");
|
|
15
|
+
const mcp_js_1 = require("@modelcontextprotocol/sdk/server/mcp.js");
|
|
16
|
+
const streamableHttp_js_1 = require("@modelcontextprotocol/sdk/server/streamableHttp.js");
|
|
17
|
+
const types_js_1 = require("@modelcontextprotocol/sdk/types.js");
|
|
18
|
+
const devices_1 = require("./devices");
|
|
19
|
+
const iob_uri_1 = require("./iob-uri");
|
|
20
|
+
const inProcessClient_1 = require("./inProcessClient");
|
|
21
|
+
Object.defineProperty(exports, "createInProcessMcp", { enumerable: true, get: function () { return inProcessClient_1.createInProcessMcp; } });
|
|
22
|
+
const package_json_1 = __importDefault(require("../package.json"));
|
|
23
|
+
const SERVER_NAME = 'iobroker-mcp';
|
|
24
|
+
const SERVER_VERSION = package_json_1.default.version;
|
|
25
|
+
/** Map the human-friendly aggregation names from the manifest to ioBroker history values. */
|
|
26
|
+
const AGG_MAP = {
|
|
27
|
+
raw: 'none',
|
|
28
|
+
min: 'min',
|
|
29
|
+
max: 'max',
|
|
30
|
+
avg: 'average',
|
|
31
|
+
sum: 'total',
|
|
32
|
+
count: 'count',
|
|
33
|
+
minmax: 'minmax',
|
|
34
|
+
percentile: 'percentile',
|
|
35
|
+
quantile: 'quantile',
|
|
36
|
+
integral: 'integral',
|
|
37
|
+
};
|
|
38
|
+
const WEB_EXTENSION_PREFIX = 'mcp/';
|
|
39
|
+
/** Languages offered for localized names. */
|
|
40
|
+
const LANGUAGES = ['en', 'de', 'ru', 'pt', 'nl', 'fr', 'it', 'es', 'pl', 'uk', 'zh-cn'];
|
|
41
|
+
/** Localize an ioBroker name to a plain string for the given language. */
|
|
42
|
+
function getText(text, language) {
|
|
43
|
+
if (!text) {
|
|
44
|
+
return '';
|
|
45
|
+
}
|
|
46
|
+
if (typeof text === 'string') {
|
|
47
|
+
return text;
|
|
48
|
+
}
|
|
49
|
+
return text[language] || text.en || '';
|
|
50
|
+
}
|
|
51
|
+
/** URI prefix for the (non-standard, ioBroker-specific) log stream resource. */
|
|
52
|
+
const LOG_URI_PREFIX = 'ioblog://';
|
|
53
|
+
/** How many recent log lines to keep for `ioblog://` resource reads. */
|
|
54
|
+
const LOG_BUFFER_SIZE = 200;
|
|
55
|
+
class McpServer {
|
|
56
|
+
adapter;
|
|
57
|
+
/** The Express app we attach routes to. Undefined in embedded in-process mode (no HTTP). */
|
|
58
|
+
app;
|
|
59
|
+
/** Active sessions keyed by session id. */
|
|
60
|
+
sessions = {};
|
|
61
|
+
/** Ref-count of adapter-level subscriptions across all sessions, keyed by "<type>:<address>" or "log". */
|
|
62
|
+
subscriptionCounts = {};
|
|
63
|
+
/** Recent log lines kept for `ioblog://` resource reads (filled while there are log subscribers). */
|
|
64
|
+
logBuffer = [];
|
|
65
|
+
config;
|
|
66
|
+
extension;
|
|
67
|
+
routerPrefix;
|
|
68
|
+
/** The ioBroker user whose permissions every MCP request runs with. */
|
|
69
|
+
defaultUser;
|
|
70
|
+
/** Default language used to localize device/room/function names. */
|
|
71
|
+
language;
|
|
72
|
+
constructor(server, webSettings, adapter, instanceSettings, app) {
|
|
73
|
+
this.app = app;
|
|
74
|
+
this.adapter = adapter;
|
|
75
|
+
// Clone the source config so we never mutate the host adapter's config object — important in
|
|
76
|
+
// embedded in-process mode, where `adapter` is a foreign adapter (e.g. admin), not mcp itself.
|
|
77
|
+
this.config = {
|
|
78
|
+
...(instanceSettings ? instanceSettings.native : adapter.config),
|
|
79
|
+
};
|
|
80
|
+
this.extension = !!instanceSettings;
|
|
81
|
+
this.routerPrefix = this.extension ? `/${WEB_EXTENSION_PREFIX}` : '/';
|
|
82
|
+
// Determine the ioBroker user whose permissions all MCP requests run with.
|
|
83
|
+
// Prefer this adapter's own setting; when embedded, fall back to the host web server's
|
|
84
|
+
// default user; finally to "admin". Always normalize to the "system.user." prefix.
|
|
85
|
+
const rawUser = this.config.defaultUser || webSettings.defaultUser || 'admin';
|
|
86
|
+
this.defaultUser = (rawUser.startsWith('system.user.') ? rawUser : `system.user.${rawUser}`);
|
|
87
|
+
this.config.defaultUser = this.defaultUser;
|
|
88
|
+
this.language = webSettings.language || 'en';
|
|
89
|
+
// Embedded callers may set the permission toggles explicitly (they don't read the mcp config).
|
|
90
|
+
if (webSettings.allowSetState !== undefined) {
|
|
91
|
+
this.config.allowSetState = webSettings.allowSetState;
|
|
92
|
+
}
|
|
93
|
+
if (webSettings.allowObjectChange !== undefined) {
|
|
94
|
+
this.config.allowObjectChange = webSettings.allowObjectChange;
|
|
95
|
+
}
|
|
96
|
+
// Permission toggles: state writes are allowed by default, object/file changes are not.
|
|
97
|
+
this.config.allowSetState = this.config.allowSetState !== false;
|
|
98
|
+
this.config.allowObjectChange = this.config.allowObjectChange === true;
|
|
99
|
+
// Receive ioBroker log messages (only forwarded once a session subscribes via requireLog).
|
|
100
|
+
this.adapter.on('log', this.onLog);
|
|
101
|
+
// Wire HTTP routes only when we own/share an Express app. In embedded in-process mode (no app)
|
|
102
|
+
// the server is reached over an in-memory transport instead, see createInProcessServer().
|
|
103
|
+
this.initRoutes();
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Build a fresh MCP SDK server with all ioBroker tools registered, ready to be connected to an
|
|
107
|
+
* arbitrary transport. Used for in-process embedding (e.g. by ioBroker.admin) over an
|
|
108
|
+
* InMemoryTransport, where there is no HTTP server/session layer. The caller owns the lifecycle
|
|
109
|
+
* of the returned server (connect it to a transport, and `close()` it when done).
|
|
110
|
+
*/
|
|
111
|
+
createInProcessServer() {
|
|
112
|
+
return this.createServer(new Set());
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Convenience factory for embedding the MCP server inside another adapter's process (no HTTP).
|
|
116
|
+
* The returned instance is not wired to any Express app; obtain a tool server via
|
|
117
|
+
* {@link createInProcessServer} and connect it to an in-memory transport.
|
|
118
|
+
*
|
|
119
|
+
* @param options embedding options (host adapter, default user, language, permission toggles)
|
|
120
|
+
* @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.
|
|
121
|
+
* @param options.defaultUser Default user
|
|
122
|
+
* @param options.language Language
|
|
123
|
+
* @param options.allowSetState Is the state creation available
|
|
124
|
+
* @param options.allowObjectChange If the object change available
|
|
125
|
+
*/
|
|
126
|
+
static createEmbedded(options) {
|
|
127
|
+
return new McpServer(null, {
|
|
128
|
+
secure: false,
|
|
129
|
+
port: 0,
|
|
130
|
+
defaultUser: options.defaultUser,
|
|
131
|
+
language: options.language,
|
|
132
|
+
allowSetState: options.allowSetState,
|
|
133
|
+
allowObjectChange: options.allowObjectChange,
|
|
134
|
+
}, options.adapter, null);
|
|
135
|
+
}
|
|
136
|
+
initRoutes() {
|
|
137
|
+
const app = this.app;
|
|
138
|
+
if (!app) {
|
|
139
|
+
// Embedded in-process mode: no HTTP transport, so there is nothing to route.
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
// The MCP endpoint. In standalone mode this is `/mcp`; as a web extension we own the
|
|
143
|
+
// `/<adapter>/` namespace (e.g. `/mcp/`) within the shared web server. `.replace` strips
|
|
144
|
+
// the trailing slash so the route matches both `/mcp` and `/mcp/` (Express, non-strict).
|
|
145
|
+
const mcpPath = this.extension ? this.routerPrefix.replace(/\/$/, '') : '/mcp';
|
|
146
|
+
// The MCP transport needs a parsed JSON body; apply the parser per-route so we never
|
|
147
|
+
// touch the body handling of the web adapter or other extensions when running embedded.
|
|
148
|
+
const jsonParser = express_1.default.json({ limit: '4mb' });
|
|
149
|
+
// Health endpoint inside our own namespace (`/status` standalone, `/mcp/status` embedded).
|
|
150
|
+
app.get(`${this.routerPrefix}status`, (_req, res) => {
|
|
151
|
+
res.json({
|
|
152
|
+
status: 'ok',
|
|
153
|
+
uptime: process.uptime(),
|
|
154
|
+
timestamp: Date.now(),
|
|
155
|
+
sessions: Object.keys(this.sessions).length,
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
// Global middleware and root/info endpoints only when we own the whole app (standalone).
|
|
159
|
+
// As an extension these would collide with the web adapter's own routes.
|
|
160
|
+
if (!this.extension) {
|
|
161
|
+
app.use((req, _res, next) => {
|
|
162
|
+
this.adapter.log.debug(`${req.method} ${req.url} from ${req.ip}`);
|
|
163
|
+
next();
|
|
164
|
+
});
|
|
165
|
+
app.get('/', (_req, res) => {
|
|
166
|
+
res.json({
|
|
167
|
+
name: 'ioBroker MCP Server',
|
|
168
|
+
version: SERVER_VERSION,
|
|
169
|
+
status: 'running',
|
|
170
|
+
mcpEndpoint: '/mcp',
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
app.get('/api/info', (_req, res) => {
|
|
174
|
+
res.json({
|
|
175
|
+
adapter: 'mcp',
|
|
176
|
+
version: SERVER_VERSION,
|
|
177
|
+
secure: this.config.secure,
|
|
178
|
+
auth: this.config.auth,
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
// --- MCP Streamable HTTP transport ---
|
|
183
|
+
app.post(mcpPath, jsonParser, (req, res) => {
|
|
184
|
+
void this.handleMcpPost(req, res);
|
|
185
|
+
});
|
|
186
|
+
app.get(mcpPath, (req, res) => {
|
|
187
|
+
void this.handleMcpSessionRequest(req, res);
|
|
188
|
+
});
|
|
189
|
+
app.delete(mcpPath, (req, res) => {
|
|
190
|
+
void this.handleMcpSessionRequest(req, res);
|
|
191
|
+
});
|
|
192
|
+
// Catch-all 404 + error handlers only in standalone mode; the web adapter provides its own.
|
|
193
|
+
if (!this.extension) {
|
|
194
|
+
app.use((req, res) => {
|
|
195
|
+
res.status(404).json({ error: 'Not Found', path: req.url });
|
|
196
|
+
});
|
|
197
|
+
app.use((err, _req, res, _next) => {
|
|
198
|
+
this.adapter.log.error(`Server error: ${err.message}`);
|
|
199
|
+
if (!res.headersSent) {
|
|
200
|
+
res.status(500).json({ error: 'Internal Server Error', message: err.message });
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* Called by the ioBroker web adapter to list this extension on its welcome/intro page.
|
|
207
|
+
* Only relevant when running embedded.
|
|
208
|
+
*/
|
|
209
|
+
welcomePage() {
|
|
210
|
+
return {
|
|
211
|
+
link: WEB_EXTENSION_PREFIX,
|
|
212
|
+
name: 'MCP Server',
|
|
213
|
+
img: 'adapter/mcp/mcp.png',
|
|
214
|
+
color: '#157ac9',
|
|
215
|
+
order: 10,
|
|
216
|
+
pro: false,
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
/** Handle client -> server messages (initialize, tools/call, ...). */
|
|
220
|
+
async handleMcpPost(req, res) {
|
|
221
|
+
const sessionId = req.headers['mcp-session-id'];
|
|
222
|
+
let transport = sessionId ? this.sessions[sessionId]?.transport : undefined;
|
|
223
|
+
if (!transport) {
|
|
224
|
+
// No session yet: only an "initialize" request may create one.
|
|
225
|
+
if (sessionId || !(0, types_js_1.isInitializeRequest)(req.body)) {
|
|
226
|
+
res.status(400).json({
|
|
227
|
+
jsonrpc: '2.0',
|
|
228
|
+
error: { code: -32000, message: 'Bad Request: No valid session ID provided' },
|
|
229
|
+
id: null,
|
|
230
|
+
});
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
const subscriptions = new Set();
|
|
234
|
+
const server = this.createServer(subscriptions);
|
|
235
|
+
transport = new streamableHttp_js_1.StreamableHTTPServerTransport({
|
|
236
|
+
sessionIdGenerator: () => (0, node_crypto_1.randomUUID)(),
|
|
237
|
+
onsessioninitialized: (sid) => {
|
|
238
|
+
this.sessions[sid] = { transport: transport, server, subscriptions };
|
|
239
|
+
this.adapter.log.debug(`MCP session initialized: ${sid}`);
|
|
240
|
+
},
|
|
241
|
+
});
|
|
242
|
+
transport.onclose = () => {
|
|
243
|
+
const sid = transport.sessionId;
|
|
244
|
+
if (sid && this.sessions[sid]) {
|
|
245
|
+
void this.cleanupSession(this.sessions[sid]);
|
|
246
|
+
delete this.sessions[sid];
|
|
247
|
+
this.adapter.log.debug(`MCP session closed: ${sid}`);
|
|
248
|
+
}
|
|
249
|
+
};
|
|
250
|
+
await server.connect(transport);
|
|
251
|
+
}
|
|
252
|
+
await transport.handleRequest(req, res, req.body);
|
|
253
|
+
}
|
|
254
|
+
/** Handle server -> client SSE stream (GET) and session termination (DELETE). */
|
|
255
|
+
async handleMcpSessionRequest(req, res) {
|
|
256
|
+
const sessionId = req.headers['mcp-session-id'];
|
|
257
|
+
const transport = sessionId ? this.sessions[sessionId]?.transport : undefined;
|
|
258
|
+
if (!transport) {
|
|
259
|
+
res.status(400).send('Invalid or missing session ID');
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
await transport.handleRequest(req, res);
|
|
263
|
+
}
|
|
264
|
+
/**
|
|
265
|
+
* Forward an ioBroker state change to subscribed sessions.
|
|
266
|
+
*
|
|
267
|
+
* Called by our own adapter (standalone) or by the host web adapter (extension mode), which
|
|
268
|
+
* invokes `stateChange` on every web extension that defines it.
|
|
269
|
+
*/
|
|
270
|
+
stateChange(id, _state) {
|
|
271
|
+
this.notifySubscribers('state', id);
|
|
272
|
+
}
|
|
273
|
+
/**
|
|
274
|
+
* Forward an ioBroker object change to subscribed sessions. Called by our own adapter
|
|
275
|
+
* (standalone) or automatically by the host web adapter (extension mode).
|
|
276
|
+
*/
|
|
277
|
+
objectChange(id, _obj) {
|
|
278
|
+
this.notifySubscribers('object', id);
|
|
279
|
+
}
|
|
280
|
+
/** Push `resources/updated` to every session subscribed to the given state/object id. */
|
|
281
|
+
notifySubscribers(type, id) {
|
|
282
|
+
for (const sid of Object.keys(this.sessions)) {
|
|
283
|
+
const session = this.sessions[sid];
|
|
284
|
+
for (const uri of session.subscriptions) {
|
|
285
|
+
if (uri.startsWith(LOG_URI_PREFIX)) {
|
|
286
|
+
continue;
|
|
287
|
+
}
|
|
288
|
+
const parsed = (0, iob_uri_1.iobUriParse)(uri);
|
|
289
|
+
if (parsed.type === type && parsed.address === id) {
|
|
290
|
+
session.server.server.sendResourceUpdated({ uri }).catch(e => {
|
|
291
|
+
this.adapter.log.debug(`Cannot notify session ${sid} about ${uri}: ${e}`);
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
/** Receive an ioBroker log line: buffer it and push `resources/updated` to ioblog subscribers. */
|
|
298
|
+
onLog = (message) => {
|
|
299
|
+
this.logBuffer.push(message);
|
|
300
|
+
if (this.logBuffer.length > LOG_BUFFER_SIZE) {
|
|
301
|
+
this.logBuffer.splice(0, this.logBuffer.length - LOG_BUFFER_SIZE);
|
|
302
|
+
}
|
|
303
|
+
for (const sid of Object.keys(this.sessions)) {
|
|
304
|
+
const session = this.sessions[sid];
|
|
305
|
+
for (const uri of session.subscriptions) {
|
|
306
|
+
if (!uri.startsWith(LOG_URI_PREFIX)) {
|
|
307
|
+
continue;
|
|
308
|
+
}
|
|
309
|
+
const source = uri.substring(LOG_URI_PREFIX.length) || 'all';
|
|
310
|
+
if (source === 'all' || source === message.from) {
|
|
311
|
+
session.server.server.sendResourceUpdated({ uri }).catch(e => {
|
|
312
|
+
this.adapter.log.debug(`Cannot notify session ${sid} about ${uri}: ${e}`);
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
};
|
|
318
|
+
/** Classify a resource URI into its subscription kind, address and ref-count key. */
|
|
319
|
+
uriKind(uri) {
|
|
320
|
+
if (uri.startsWith(LOG_URI_PREFIX)) {
|
|
321
|
+
return { kind: 'log', address: uri.substring(LOG_URI_PREFIX.length) || 'all', key: 'log' };
|
|
322
|
+
}
|
|
323
|
+
const { type, address } = (0, iob_uri_1.iobUriParse)(uri);
|
|
324
|
+
if (type === 'state' || type === 'object') {
|
|
325
|
+
return { kind: type, address, key: `${type}:${address}` };
|
|
326
|
+
}
|
|
327
|
+
return { kind: 'other', address: '', key: '' };
|
|
328
|
+
}
|
|
329
|
+
/** Add an adapter-level subscription, subscribing on the adapter only on the first reference. */
|
|
330
|
+
async refSubscribe(uri) {
|
|
331
|
+
const { kind, address, key } = this.uriKind(uri);
|
|
332
|
+
if (kind === 'other') {
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
if (!this.subscriptionCounts[key]) {
|
|
336
|
+
this.subscriptionCounts[key] = 0;
|
|
337
|
+
if (kind === 'log') {
|
|
338
|
+
await this.adapter.requireLog?.(true);
|
|
339
|
+
}
|
|
340
|
+
else if (kind === 'object') {
|
|
341
|
+
await this.adapter.subscribeForeignObjectsAsync(address);
|
|
342
|
+
}
|
|
343
|
+
else {
|
|
344
|
+
await this.adapter.subscribeForeignStatesAsync(address);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
this.subscriptionCounts[key]++;
|
|
348
|
+
}
|
|
349
|
+
/** Drop an adapter-level subscription, unsubscribing on the adapter when the last reference goes. */
|
|
350
|
+
async refUnsubscribe(uri) {
|
|
351
|
+
const { kind, address, key } = this.uriKind(uri);
|
|
352
|
+
if (kind === 'other' || !this.subscriptionCounts[key]) {
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
this.subscriptionCounts[key]--;
|
|
356
|
+
if (this.subscriptionCounts[key] <= 0) {
|
|
357
|
+
delete this.subscriptionCounts[key];
|
|
358
|
+
if (kind === 'log') {
|
|
359
|
+
await this.adapter.requireLog?.(false);
|
|
360
|
+
}
|
|
361
|
+
else if (kind === 'object') {
|
|
362
|
+
await this.adapter.unsubscribeForeignObjectsAsync(address);
|
|
363
|
+
}
|
|
364
|
+
else {
|
|
365
|
+
await this.adapter.unsubscribeForeignStatesAsync(address);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
/** Release all subscriptions held by a session (on close). */
|
|
370
|
+
async cleanupSession(session) {
|
|
371
|
+
for (const uri of session.subscriptions) {
|
|
372
|
+
await this.refUnsubscribe(uri);
|
|
373
|
+
}
|
|
374
|
+
session.subscriptions.clear();
|
|
375
|
+
}
|
|
376
|
+
/**
|
|
377
|
+
* Create a new MCP SDK server with all ioBroker tools registered.
|
|
378
|
+
*
|
|
379
|
+
* @param subscriptions the per-session set of subscribed state ids (mutated by subscribe/unsubscribe)
|
|
380
|
+
*/
|
|
381
|
+
createServer(subscriptions) {
|
|
382
|
+
const server = new mcp_js_1.McpServer({ name: SERVER_NAME, version: SERVER_VERSION }, { capabilities: { tools: {}, resources: { subscribe: true } } });
|
|
383
|
+
const ok = (data) => ({ content: [{ type: 'text', text: JSON.stringify(data) }] });
|
|
384
|
+
const fail = (error) => {
|
|
385
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
386
|
+
this.adapter.log.warn(`MCP tool error: ${message}`);
|
|
387
|
+
return { content: [{ type: 'text', text: JSON.stringify({ ok: false, error: message }) }], isError: true };
|
|
388
|
+
};
|
|
389
|
+
server.registerTool('get_states', {
|
|
390
|
+
description: 'Retrieve the current value of one or multiple states. IDs may contain wildcards, ' +
|
|
391
|
+
'e.g. "hue.0.*.brightness" expands to all matching states.',
|
|
392
|
+
inputSchema: { ids: zod_1.z.array(zod_1.z.string()).describe('Array of state IDs (wildcards "*" allowed)') },
|
|
393
|
+
}, async ({ ids }) => {
|
|
394
|
+
try {
|
|
395
|
+
return ok({ ok: true, data: { states: await this.getStates(ids) } });
|
|
396
|
+
}
|
|
397
|
+
catch (e) {
|
|
398
|
+
return fail(e);
|
|
399
|
+
}
|
|
400
|
+
});
|
|
401
|
+
if (this.config.allowSetState) {
|
|
402
|
+
server.registerTool('set_state', {
|
|
403
|
+
description: 'Set the value of a state. The value is coerced to the state type (boolean/number/string).',
|
|
404
|
+
inputSchema: {
|
|
405
|
+
id: zod_1.z.string().describe('State ID'),
|
|
406
|
+
value: zod_1.z.any().describe('New value (type depends on the state)'),
|
|
407
|
+
options: zod_1.z
|
|
408
|
+
.object({ ack: zod_1.z.boolean().default(false), expire: zod_1.z.number().int().nullable().optional() })
|
|
409
|
+
.optional(),
|
|
410
|
+
},
|
|
411
|
+
}, async ({ id, value, options }) => {
|
|
412
|
+
try {
|
|
413
|
+
const written = await this.setState(id, value, options?.ack ?? false);
|
|
414
|
+
return ok({ ok: true, data: { id, value: written } });
|
|
415
|
+
}
|
|
416
|
+
catch (e) {
|
|
417
|
+
return fail(e);
|
|
418
|
+
}
|
|
419
|
+
});
|
|
420
|
+
server.registerTool('set_states', {
|
|
421
|
+
description: 'Set multiple states in one call (e.g. for scenes or group actions like "all lights off"). ' +
|
|
422
|
+
'Each value is coerced to its state type. Failures of single states do not abort the rest.',
|
|
423
|
+
inputSchema: {
|
|
424
|
+
states: zod_1.z
|
|
425
|
+
.array(zod_1.z.object({
|
|
426
|
+
id: zod_1.z.string().describe('State ID'),
|
|
427
|
+
value: zod_1.z.any().describe('New value (type depends on the state)'),
|
|
428
|
+
ack: zod_1.z.boolean().optional().describe('Acknowledge flag (default false)'),
|
|
429
|
+
}))
|
|
430
|
+
.describe('Array of state/value pairs to write'),
|
|
431
|
+
},
|
|
432
|
+
}, async ({ states }) => {
|
|
433
|
+
try {
|
|
434
|
+
return ok({ ok: true, data: { results: await this.setStates(states) } });
|
|
435
|
+
}
|
|
436
|
+
catch (e) {
|
|
437
|
+
return fail(e);
|
|
438
|
+
}
|
|
439
|
+
});
|
|
440
|
+
}
|
|
441
|
+
server.registerTool('get_logs', {
|
|
442
|
+
description: 'Retrieve recent ioBroker log lines from the host log file. Each entry has {ts, level, ' +
|
|
443
|
+
'source, message}. Optionally filter by `level` (error/warn/info/debug), by `adapter` ' +
|
|
444
|
+
'(source, e.g. "hm-rpc.0" or "hm-rpc"), or from `from_ts` (ms). When a level filter is set, ' +
|
|
445
|
+
'a larger window of the log is scanned so errors/warnings are not hidden behind debug spam.',
|
|
446
|
+
inputSchema: {
|
|
447
|
+
level: zod_1.z.array(zod_1.z.enum(['error', 'warn', 'info', 'debug'])).optional(),
|
|
448
|
+
from_ts: zod_1.z.number().int().optional(),
|
|
449
|
+
limit: zod_1.z.number().int().default(200),
|
|
450
|
+
adapter: zod_1.z.string().optional(),
|
|
451
|
+
},
|
|
452
|
+
}, async (args) => {
|
|
453
|
+
try {
|
|
454
|
+
return ok({ ok: true, data: { logs: await this.getLogs(args) } });
|
|
455
|
+
}
|
|
456
|
+
catch (e) {
|
|
457
|
+
return fail(e);
|
|
458
|
+
}
|
|
459
|
+
});
|
|
460
|
+
server.registerTool('ping_host', {
|
|
461
|
+
description: 'Check whether a network device is reachable — useful to diagnose adapter connection ' +
|
|
462
|
+
'errors (e.g. a "connect ETIMEDOUT 192.168.10.5:2001"). Runs an ICMP ping to `host` and, ' +
|
|
463
|
+
'if `port` is given, also a TCP connect to that port (which tests the actual service, not ' +
|
|
464
|
+
'just the host). `host` may be an IP or hostname. Returns the overall `reachable` verdict, ' +
|
|
465
|
+
'ICMP reachability/latency and, when requested, whether the TCP port is open. If the `ping` ' +
|
|
466
|
+
'command is NOT installed on the ioBroker host, ICMP is skipped (icmp.unavailable=true), ' +
|
|
467
|
+
'reachability is determined via a TCP fallback instead, and the result carries a `note`/' +
|
|
468
|
+
'`recommendation` with the exact command to install ping — relay that recommendation to the user.',
|
|
469
|
+
inputSchema: {
|
|
470
|
+
host: zod_1.z.string(),
|
|
471
|
+
port: zod_1.z.number().int().min(1).max(65535).optional(),
|
|
472
|
+
count: zod_1.z.number().int().min(1).max(10).default(2),
|
|
473
|
+
timeout: zod_1.z.number().int().min(100).max(20000).default(2000),
|
|
474
|
+
},
|
|
475
|
+
}, async (args) => {
|
|
476
|
+
try {
|
|
477
|
+
return ok({ ok: true, data: await this.pingHost(args) });
|
|
478
|
+
}
|
|
479
|
+
catch (e) {
|
|
480
|
+
return fail(e);
|
|
481
|
+
}
|
|
482
|
+
});
|
|
483
|
+
server.registerTool('system_info', { description: 'Get system and js-controller information' }, async () => {
|
|
484
|
+
try {
|
|
485
|
+
return ok({ ok: true, data: await this.getSystemInfo() });
|
|
486
|
+
}
|
|
487
|
+
catch (e) {
|
|
488
|
+
return fail(e);
|
|
489
|
+
}
|
|
490
|
+
});
|
|
491
|
+
server.registerTool('search_objects', {
|
|
492
|
+
description: 'Search objects and states by keyword (matched against ID and name) with optional ' +
|
|
493
|
+
'filters for object type, role, room and source adapter instance',
|
|
494
|
+
inputSchema: {
|
|
495
|
+
query: zod_1.z.string().describe('Keyword to search for in object IDs and names'),
|
|
496
|
+
type: zod_1.z
|
|
497
|
+
.string()
|
|
498
|
+
.optional()
|
|
499
|
+
.describe('Filter by object type, e.g. state, channel, device, enum, script, instance'),
|
|
500
|
+
role: zod_1.z.string().optional(),
|
|
501
|
+
room: zod_1.z.string().optional(),
|
|
502
|
+
adapter: zod_1.z.string().optional().describe('Filter by source adapter instance, e.g. "hue.0"'),
|
|
503
|
+
limit: zod_1.z.number().int().default(50),
|
|
504
|
+
},
|
|
505
|
+
}, async (args) => {
|
|
506
|
+
try {
|
|
507
|
+
return ok({ ok: true, data: { results: await this.searchObjects(args) } });
|
|
508
|
+
}
|
|
509
|
+
catch (e) {
|
|
510
|
+
return fail(e);
|
|
511
|
+
}
|
|
512
|
+
});
|
|
513
|
+
server.registerTool('list_devices', {
|
|
514
|
+
description: 'List detected devices grouped by room. Uses the ioBroker type-detector to turn ' +
|
|
515
|
+
'raw states/channels/devices into functional devices with named controls.',
|
|
516
|
+
inputSchema: {
|
|
517
|
+
language: zod_1.z
|
|
518
|
+
.enum(['en', 'de', 'ru', 'pt', 'nl', 'fr', 'it', 'es', 'pl', 'uk', 'zh-cn'])
|
|
519
|
+
.optional()
|
|
520
|
+
.describe('Language for device/room/function names (defaults to the adapter language)'),
|
|
521
|
+
room: zod_1.z.string().optional().describe('Filter the result to a single room (by name)'),
|
|
522
|
+
},
|
|
523
|
+
}, async (args) => {
|
|
524
|
+
try {
|
|
525
|
+
return ok({ ok: true, data: { rooms: await this.listDevices(args) } });
|
|
526
|
+
}
|
|
527
|
+
catch (e) {
|
|
528
|
+
return fail(e);
|
|
529
|
+
}
|
|
530
|
+
});
|
|
531
|
+
server.registerTool('history_query', {
|
|
532
|
+
description: 'Query historical values of a state (requires a history adapter)',
|
|
533
|
+
inputSchema: {
|
|
534
|
+
id: zod_1.z.string(),
|
|
535
|
+
from: zod_1.z.string().optional().describe('Start time (ISO8601)'),
|
|
536
|
+
to: zod_1.z.string().optional().describe('End time (ISO8601)'),
|
|
537
|
+
agg: zod_1.z
|
|
538
|
+
.enum([
|
|
539
|
+
'raw',
|
|
540
|
+
'min',
|
|
541
|
+
'max',
|
|
542
|
+
'avg',
|
|
543
|
+
'sum',
|
|
544
|
+
'count',
|
|
545
|
+
'minmax',
|
|
546
|
+
'percentile',
|
|
547
|
+
'quantile',
|
|
548
|
+
'integral',
|
|
549
|
+
])
|
|
550
|
+
.default('raw'),
|
|
551
|
+
percentile: zod_1.z
|
|
552
|
+
.number()
|
|
553
|
+
.min(0)
|
|
554
|
+
.max(100)
|
|
555
|
+
.optional()
|
|
556
|
+
.describe('Percentile (0-100), only used with agg=percentile'),
|
|
557
|
+
quantile: zod_1.z
|
|
558
|
+
.number()
|
|
559
|
+
.min(0)
|
|
560
|
+
.max(1)
|
|
561
|
+
.optional()
|
|
562
|
+
.describe('Quantile (0-1), only used with agg=quantile'),
|
|
563
|
+
interval: zod_1.z.string().optional().describe('Aggregation interval, e.g. 15m, 1h'),
|
|
564
|
+
limit: zod_1.z.number().int().default(1000),
|
|
565
|
+
},
|
|
566
|
+
}, async (args) => {
|
|
567
|
+
try {
|
|
568
|
+
return ok({ ok: true, data: await this.historyQuery(args) });
|
|
569
|
+
}
|
|
570
|
+
catch (e) {
|
|
571
|
+
return fail(e);
|
|
572
|
+
}
|
|
573
|
+
});
|
|
574
|
+
server.registerTool('list_instances', { description: 'List all adapter instances with their status' }, async () => {
|
|
575
|
+
try {
|
|
576
|
+
return ok({ ok: true, data: { instances: await this.listInstances() } });
|
|
577
|
+
}
|
|
578
|
+
catch (e) {
|
|
579
|
+
return fail(e);
|
|
580
|
+
}
|
|
581
|
+
});
|
|
582
|
+
server.registerTool('list_hosts', { description: 'List all ioBroker hosts with their status' }, async () => {
|
|
583
|
+
try {
|
|
584
|
+
return ok({ ok: true, data: { hosts: await this.listHosts() } });
|
|
585
|
+
}
|
|
586
|
+
catch (e) {
|
|
587
|
+
return fail(e);
|
|
588
|
+
}
|
|
589
|
+
});
|
|
590
|
+
server.registerTool('list_adapters', {
|
|
591
|
+
description: 'List all installed adapters with metadata (version, title, description, keywords). ' +
|
|
592
|
+
'Unlike list_instances this lists what is installed, not what is running.',
|
|
593
|
+
inputSchema: {
|
|
594
|
+
language: zod_1.z
|
|
595
|
+
.enum(LANGUAGES)
|
|
596
|
+
.optional()
|
|
597
|
+
.describe('Language for title/description (defaults to the adapter language)'),
|
|
598
|
+
},
|
|
599
|
+
}, async ({ language }) => {
|
|
600
|
+
try {
|
|
601
|
+
return ok({ ok: true, data: { adapters: await this.listAdapters(language) } });
|
|
602
|
+
}
|
|
603
|
+
catch (e) {
|
|
604
|
+
return fail(e);
|
|
605
|
+
}
|
|
606
|
+
});
|
|
607
|
+
server.registerTool('search_adapter_repository', {
|
|
608
|
+
description: 'Search the ioBroker adapter REPOSITORY (all installable adapters, not just the ' +
|
|
609
|
+
'installed ones) by keyword, matched against the adapter name, title, description and ' +
|
|
610
|
+
'keywords. Use this to recommend which adapter to install for a device or service. ' +
|
|
611
|
+
'Reads the already-downloaded repository object (fast, no network).',
|
|
612
|
+
inputSchema: {
|
|
613
|
+
query: zod_1.z.string().describe('Keyword to search for, e.g. "philips hue", "zigbee", "mqtt"'),
|
|
614
|
+
type: zod_1.z
|
|
615
|
+
.string()
|
|
616
|
+
.optional()
|
|
617
|
+
.describe('Filter by adapter category/type, e.g. "lighting", "climate-control", "hardware"'),
|
|
618
|
+
onlyNotInstalled: zod_1.z
|
|
619
|
+
.boolean()
|
|
620
|
+
.optional()
|
|
621
|
+
.describe('If true, return only adapters that are not installed yet'),
|
|
622
|
+
language: zod_1.z.enum(LANGUAGES).optional().describe('Language for title/description'),
|
|
623
|
+
limit: zod_1.z.number().int().default(20),
|
|
624
|
+
},
|
|
625
|
+
}, async (args) => {
|
|
626
|
+
try {
|
|
627
|
+
return ok({ ok: true, data: { adapters: await this.searchAdapterRepository(args) } });
|
|
628
|
+
}
|
|
629
|
+
catch (e) {
|
|
630
|
+
return fail(e);
|
|
631
|
+
}
|
|
632
|
+
});
|
|
633
|
+
const enumInput = {
|
|
634
|
+
language: zod_1.z.enum(LANGUAGES).optional().describe('Language for names (defaults to the adapter language)'),
|
|
635
|
+
withIcons: zod_1.z.boolean().optional().describe('Include the icons of the enum and its members'),
|
|
636
|
+
};
|
|
637
|
+
server.registerTool('list_rooms', { description: 'List all rooms (enum.rooms.*) with their members and metadata', inputSchema: enumInput }, async ({ language, withIcons }) => {
|
|
638
|
+
try {
|
|
639
|
+
return ok({ ok: true, data: { rooms: await this.readEnums('rooms', language, withIcons) } });
|
|
640
|
+
}
|
|
641
|
+
catch (e) {
|
|
642
|
+
return fail(e);
|
|
643
|
+
}
|
|
644
|
+
});
|
|
645
|
+
server.registerTool('list_functions', {
|
|
646
|
+
description: 'List all functions (enum.functions.*) with their members and metadata',
|
|
647
|
+
inputSchema: enumInput,
|
|
648
|
+
}, async ({ language, withIcons }) => {
|
|
649
|
+
try {
|
|
650
|
+
return ok({
|
|
651
|
+
ok: true,
|
|
652
|
+
data: { functions: await this.readEnums('functions', language, withIcons) },
|
|
653
|
+
});
|
|
654
|
+
}
|
|
655
|
+
catch (e) {
|
|
656
|
+
return fail(e);
|
|
657
|
+
}
|
|
658
|
+
});
|
|
659
|
+
server.registerTool('get_object', {
|
|
660
|
+
description: 'Read a single ioBroker object by its ID',
|
|
661
|
+
inputSchema: { id: zod_1.z.string().describe('Object ID, e.g. system.adapter.admin.0 or hm-rpc.0.ABC') },
|
|
662
|
+
}, async ({ id }) => {
|
|
663
|
+
try {
|
|
664
|
+
const object = await this.adapter.getForeignObjectAsync(id, { user: this.defaultUser });
|
|
665
|
+
return ok({ ok: true, data: { object: object ?? null } });
|
|
666
|
+
}
|
|
667
|
+
catch (e) {
|
|
668
|
+
return fail(e);
|
|
669
|
+
}
|
|
670
|
+
});
|
|
671
|
+
server.registerTool('read_file', {
|
|
672
|
+
description: 'Read a file from an adapter file storage, e.g. "vis-2.0/main/vis-views.json"',
|
|
673
|
+
inputSchema: {
|
|
674
|
+
path: zod_1.z.string().describe('Path as "<adapter>/<dir>/<file>"'),
|
|
675
|
+
base64: zod_1.z.boolean().optional().describe('Return binary content base64-encoded'),
|
|
676
|
+
},
|
|
677
|
+
}, async ({ path, base64 }) => {
|
|
678
|
+
try {
|
|
679
|
+
return ok({ ok: true, data: await this.readFile(path, base64) });
|
|
680
|
+
}
|
|
681
|
+
catch (e) {
|
|
682
|
+
return fail(e);
|
|
683
|
+
}
|
|
684
|
+
});
|
|
685
|
+
server.registerTool('list_files', {
|
|
686
|
+
description: 'List a directory in an adapter file storage, e.g. "vis-2.0/main" or "0_userdata.0"',
|
|
687
|
+
inputSchema: { path: zod_1.z.string().describe('Path as "<adapter>[/<dir>]"') },
|
|
688
|
+
}, async ({ path }) => {
|
|
689
|
+
try {
|
|
690
|
+
return ok({ ok: true, data: { path, files: await this.listFiles(path) } });
|
|
691
|
+
}
|
|
692
|
+
catch (e) {
|
|
693
|
+
return fail(e);
|
|
694
|
+
}
|
|
695
|
+
});
|
|
696
|
+
server.registerTool('file_exists', {
|
|
697
|
+
description: 'Check whether a file exists in an adapter file storage',
|
|
698
|
+
inputSchema: { path: zod_1.z.string().describe('Path as "<adapter>/<dir>/<file>"') },
|
|
699
|
+
}, async ({ path }) => {
|
|
700
|
+
try {
|
|
701
|
+
const { adapterName, fileName } = McpServer.parseFilePath(path);
|
|
702
|
+
const exists = await this.adapter.fileExistsAsync(adapterName, fileName, {
|
|
703
|
+
user: this.defaultUser,
|
|
704
|
+
});
|
|
705
|
+
return ok({ ok: true, data: { path, exists: !!exists } });
|
|
706
|
+
}
|
|
707
|
+
catch (e) {
|
|
708
|
+
return fail(e);
|
|
709
|
+
}
|
|
710
|
+
});
|
|
711
|
+
// Always-on log writing (does not change states or objects).
|
|
712
|
+
server.registerTool('write_log', {
|
|
713
|
+
description: 'Write a message to the ioBroker log',
|
|
714
|
+
inputSchema: {
|
|
715
|
+
message: zod_1.z.string(),
|
|
716
|
+
level: zod_1.z.enum(['error', 'warn', 'info', 'debug']).default('info'),
|
|
717
|
+
},
|
|
718
|
+
}, ({ message, level }) => {
|
|
719
|
+
try {
|
|
720
|
+
this.adapter.log[level](message);
|
|
721
|
+
return Promise.resolve(ok({ ok: true, data: { message, level } }));
|
|
722
|
+
}
|
|
723
|
+
catch (e) {
|
|
724
|
+
return Promise.resolve(fail(e));
|
|
725
|
+
}
|
|
726
|
+
});
|
|
727
|
+
// Object/file changes are gated behind the "allowObjectChange" option (off by default).
|
|
728
|
+
if (this.config.allowObjectChange) {
|
|
729
|
+
server.registerTool('set_object', {
|
|
730
|
+
description: 'Create or update an ioBroker object. An existing object is updated by merging the ' +
|
|
731
|
+
'provided common/native; a missing one is created from the provided object.',
|
|
732
|
+
inputSchema: {
|
|
733
|
+
id: zod_1.z.string().describe('Object ID'),
|
|
734
|
+
obj: zod_1.z
|
|
735
|
+
.record(zod_1.z.string(), zod_1.z.any())
|
|
736
|
+
.describe('Partial object, e.g. { "type": "state", "common": {...}, "native": {...} }'),
|
|
737
|
+
},
|
|
738
|
+
}, async ({ id, obj }) => {
|
|
739
|
+
try {
|
|
740
|
+
const result = await this.setObject(id, obj);
|
|
741
|
+
return ok({ ok: true, data: result });
|
|
742
|
+
}
|
|
743
|
+
catch (e) {
|
|
744
|
+
return fail(e);
|
|
745
|
+
}
|
|
746
|
+
});
|
|
747
|
+
server.registerTool('delete_object', {
|
|
748
|
+
description: 'Delete an ioBroker object (and optionally all its children). ' +
|
|
749
|
+
'Be careful: deleting objects that were not created by the user can break adapters.',
|
|
750
|
+
inputSchema: {
|
|
751
|
+
id: zod_1.z.string().describe('Object ID'),
|
|
752
|
+
recursive: zod_1.z.boolean().optional().describe('Also delete all child objects'),
|
|
753
|
+
},
|
|
754
|
+
}, async ({ id, recursive }) => {
|
|
755
|
+
try {
|
|
756
|
+
await this.deleteObject(id, recursive);
|
|
757
|
+
return ok({ ok: true, data: { id, deleted: true } });
|
|
758
|
+
}
|
|
759
|
+
catch (e) {
|
|
760
|
+
return fail(e);
|
|
761
|
+
}
|
|
762
|
+
});
|
|
763
|
+
server.registerTool('create_state', {
|
|
764
|
+
description: 'Create a new state object (e.g. under "0_userdata.0."). Fails if the object already ' +
|
|
765
|
+
'exists - use set_object to modify existing objects.',
|
|
766
|
+
inputSchema: {
|
|
767
|
+
id: zod_1.z.string().describe('Full state ID, e.g. "0_userdata.0.myState"'),
|
|
768
|
+
name: zod_1.z.string().optional().describe('Display name (defaults to the last ID segment)'),
|
|
769
|
+
type: zod_1.z
|
|
770
|
+
.enum(['boolean', 'number', 'string', 'array', 'object', 'mixed'])
|
|
771
|
+
.default('mixed')
|
|
772
|
+
.describe('Value type of the state'),
|
|
773
|
+
role: zod_1.z.string().default('state').describe('Role, e.g. switch.light, value.temperature'),
|
|
774
|
+
read: zod_1.z.boolean().default(true),
|
|
775
|
+
write: zod_1.z.boolean().default(true),
|
|
776
|
+
unit: zod_1.z.string().optional(),
|
|
777
|
+
min: zod_1.z.number().optional(),
|
|
778
|
+
max: zod_1.z.number().optional(),
|
|
779
|
+
step: zod_1.z.number().optional(),
|
|
780
|
+
def: zod_1.z.any().optional().describe('Initial value (written with ack=true)'),
|
|
781
|
+
desc: zod_1.z.string().optional().describe('Description'),
|
|
782
|
+
},
|
|
783
|
+
}, async (args) => {
|
|
784
|
+
try {
|
|
785
|
+
return ok({ ok: true, data: await this.createState(args) });
|
|
786
|
+
}
|
|
787
|
+
catch (e) {
|
|
788
|
+
return fail(e);
|
|
789
|
+
}
|
|
790
|
+
});
|
|
791
|
+
server.registerTool('create_scene', {
|
|
792
|
+
description: 'Create or update a scene for the ioBroker "scenes" adapter: a named collection of ' +
|
|
793
|
+
'state/value pairs applied together (e.g. "movie night" dims lights and closes blinds). ' +
|
|
794
|
+
'Activate the scene by setting its state to true. Requires an installed scene instance.',
|
|
795
|
+
inputSchema: {
|
|
796
|
+
name: zod_1.z.string().describe('Scene name, becomes part of the ID (e.g. "movie_night")'),
|
|
797
|
+
members: zod_1.z
|
|
798
|
+
.array(zod_1.z.object({
|
|
799
|
+
id: zod_1.z.string().describe('State ID to set'),
|
|
800
|
+
value: zod_1.z.any().describe('Value applied when the scene is activated'),
|
|
801
|
+
}))
|
|
802
|
+
.describe('State/value pairs that define the scene'),
|
|
803
|
+
instance: zod_1.z.string().optional().describe('Scene adapter instance (default "scene.0")'),
|
|
804
|
+
description: zod_1.z.string().optional(),
|
|
805
|
+
},
|
|
806
|
+
}, async (args) => {
|
|
807
|
+
try {
|
|
808
|
+
return ok({ ok: true, data: await this.createScene(args) });
|
|
809
|
+
}
|
|
810
|
+
catch (e) {
|
|
811
|
+
return fail(e);
|
|
812
|
+
}
|
|
813
|
+
});
|
|
814
|
+
server.registerTool('write_file', {
|
|
815
|
+
description: 'Write a file to an adapter file storage, e.g. "vis-2.0/main/vis-views.json"',
|
|
816
|
+
inputSchema: {
|
|
817
|
+
path: zod_1.z.string().describe('Path as "<adapter>/<dir>/<file>"'),
|
|
818
|
+
content: zod_1.z.string().describe('File content (UTF-8, or base64 when base64=true)'),
|
|
819
|
+
base64: zod_1.z.boolean().optional().describe('Treat content as base64-encoded binary'),
|
|
820
|
+
},
|
|
821
|
+
}, async ({ path, content, base64 }) => {
|
|
822
|
+
try {
|
|
823
|
+
await this.writeFile(path, content, base64);
|
|
824
|
+
return ok({ ok: true, data: { path } });
|
|
825
|
+
}
|
|
826
|
+
catch (e) {
|
|
827
|
+
return fail(e);
|
|
828
|
+
}
|
|
829
|
+
});
|
|
830
|
+
server.registerTool('delete_file', {
|
|
831
|
+
description: 'Delete a file from an adapter file storage',
|
|
832
|
+
inputSchema: { path: zod_1.z.string().describe('Path as "<adapter>/<dir>/<file>"') },
|
|
833
|
+
}, async ({ path }) => {
|
|
834
|
+
try {
|
|
835
|
+
const { adapterName, fileName } = McpServer.parseFilePath(path);
|
|
836
|
+
await this.adapter.delFileAsync(adapterName, fileName, { user: this.defaultUser });
|
|
837
|
+
return ok({ ok: true, data: { path, deleted: true } });
|
|
838
|
+
}
|
|
839
|
+
catch (e) {
|
|
840
|
+
return fail(e);
|
|
841
|
+
}
|
|
842
|
+
});
|
|
843
|
+
server.registerTool('rename_file', {
|
|
844
|
+
description: 'Rename or move a file/directory within the same adapter file storage, ' +
|
|
845
|
+
'e.g. "vis-2.0/main/a.json" -> "vis-2.0/backup/a.json"',
|
|
846
|
+
inputSchema: {
|
|
847
|
+
path: zod_1.z.string().describe('Current path as "<adapter>/<dir>/<file>"'),
|
|
848
|
+
new_path: zod_1.z.string().describe('New path as "<adapter>/<dir>/<file>" (same adapter)'),
|
|
849
|
+
},
|
|
850
|
+
}, async ({ path, new_path }) => {
|
|
851
|
+
try {
|
|
852
|
+
await this.renameFile(path, new_path);
|
|
853
|
+
return ok({ ok: true, data: { path: new_path } });
|
|
854
|
+
}
|
|
855
|
+
catch (e) {
|
|
856
|
+
return fail(e);
|
|
857
|
+
}
|
|
858
|
+
});
|
|
859
|
+
server.registerTool('mkdir', {
|
|
860
|
+
description: 'Create a directory in an adapter file storage, e.g. "0_userdata.0/my-folder"',
|
|
861
|
+
inputSchema: { path: zod_1.z.string().describe('Path as "<adapter>/<dir>"') },
|
|
862
|
+
}, async ({ path }) => {
|
|
863
|
+
try {
|
|
864
|
+
const { adapterName, dirName } = McpServer.parseDirPath(path);
|
|
865
|
+
if (!dirName) {
|
|
866
|
+
throw new Error('Path must contain a directory after the adapter, e.g. "0_userdata.0/my-folder"');
|
|
867
|
+
}
|
|
868
|
+
await this.adapter.mkdirAsync(adapterName, dirName, { user: this.defaultUser });
|
|
869
|
+
return ok({ ok: true, data: { path } });
|
|
870
|
+
}
|
|
871
|
+
catch (e) {
|
|
872
|
+
return fail(e);
|
|
873
|
+
}
|
|
874
|
+
});
|
|
875
|
+
}
|
|
876
|
+
// --- Resources: expose states and objects via the canonical ioBroker URI scheme ---
|
|
877
|
+
// States as `iobstate://<id>`, objects as `iobobject://<id>`. On change the server pushes a
|
|
878
|
+
// `notifications/resources/updated` over the session's SSE stream and the client re-reads.
|
|
879
|
+
server.registerResource('state', new mcp_js_1.ResourceTemplate('iobstate://{id}', { list: undefined }), {
|
|
880
|
+
title: 'ioBroker state',
|
|
881
|
+
description: 'A single ioBroker state value, addressed as iobstate://<id>',
|
|
882
|
+
mimeType: 'application/json',
|
|
883
|
+
}, async (uri, variables) => {
|
|
884
|
+
const id = decodeURIComponent(String(variables.id));
|
|
885
|
+
const state = await this.adapter.getForeignStateAsync(id, { user: this.defaultUser });
|
|
886
|
+
const body = state
|
|
887
|
+
? { id, val: state.val, ack: state.ack, ts: state.ts, lc: state.lc, q: state.q }
|
|
888
|
+
: { id, val: null };
|
|
889
|
+
return { contents: [{ uri: uri.href, mimeType: 'application/json', text: JSON.stringify(body) }] };
|
|
890
|
+
});
|
|
891
|
+
server.registerResource('object', new mcp_js_1.ResourceTemplate('iobobject://{id}', { list: undefined }), {
|
|
892
|
+
title: 'ioBroker object',
|
|
893
|
+
description: 'A single ioBroker object, addressed as iobobject://<id>',
|
|
894
|
+
mimeType: 'application/json',
|
|
895
|
+
}, async (uri, variables) => {
|
|
896
|
+
const id = decodeURIComponent(String(variables.id));
|
|
897
|
+
const obj = await this.adapter.getForeignObjectAsync(id, { user: this.defaultUser });
|
|
898
|
+
return {
|
|
899
|
+
contents: [{ uri: uri.href, mimeType: 'application/json', text: JSON.stringify(obj ?? null) }],
|
|
900
|
+
};
|
|
901
|
+
});
|
|
902
|
+
// Log stream: `ioblog://all` for every source, or `ioblog://<source>` (e.g. ioblog://admin.0).
|
|
903
|
+
// Subscribe to receive `resources/updated` on each new log line, then re-read for recent lines.
|
|
904
|
+
server.registerResource('log', new mcp_js_1.ResourceTemplate(`${LOG_URI_PREFIX}{source}`, { list: undefined }), {
|
|
905
|
+
title: 'ioBroker log stream',
|
|
906
|
+
description: 'Recent log lines, addressed as ioblog://all or ioblog://<source>',
|
|
907
|
+
mimeType: 'application/json',
|
|
908
|
+
}, (uri, variables) => {
|
|
909
|
+
const source = decodeURIComponent(String(variables.source)) || 'all';
|
|
910
|
+
const logs = this.logBuffer
|
|
911
|
+
.filter(m => source === 'all' || m.from === source)
|
|
912
|
+
.map(m => ({ ts: m.ts, level: m.severity, source: m.from, message: m.message }));
|
|
913
|
+
return Promise.resolve({
|
|
914
|
+
contents: [{ uri: uri.href, mimeType: 'application/json', text: JSON.stringify({ source, logs }) }],
|
|
915
|
+
});
|
|
916
|
+
});
|
|
917
|
+
// Manual subscribe/unsubscribe handlers (the high-level McpServer does not provide them).
|
|
918
|
+
// Subscriptions are stored as the full canonical URI; supported for states and objects.
|
|
919
|
+
server.server.setRequestHandler(types_js_1.SubscribeRequestSchema, async (request) => {
|
|
920
|
+
const uri = request.params.uri;
|
|
921
|
+
if (this.uriKind(uri).kind !== 'other' && !subscriptions.has(uri)) {
|
|
922
|
+
subscriptions.add(uri);
|
|
923
|
+
await this.refSubscribe(uri);
|
|
924
|
+
}
|
|
925
|
+
return {};
|
|
926
|
+
});
|
|
927
|
+
server.server.setRequestHandler(types_js_1.UnsubscribeRequestSchema, async (request) => {
|
|
928
|
+
const uri = request.params.uri;
|
|
929
|
+
if (subscriptions.has(uri)) {
|
|
930
|
+
subscriptions.delete(uri);
|
|
931
|
+
await this.refUnsubscribe(uri);
|
|
932
|
+
}
|
|
933
|
+
return {};
|
|
934
|
+
});
|
|
935
|
+
return server;
|
|
936
|
+
}
|
|
937
|
+
// ---------------------------------------------------------------------
|
|
938
|
+
// Tool implementations (return plain data; the wrappers serialize them)
|
|
939
|
+
// ---------------------------------------------------------------------
|
|
940
|
+
/** Build the result entry for one state. */
|
|
941
|
+
static stateEntry(id, state) {
|
|
942
|
+
if (!state) {
|
|
943
|
+
return { id, value: null, ack: false, ts: Date.now() };
|
|
944
|
+
}
|
|
945
|
+
const entry = { id, value: state.val, ack: state.ack, ts: state.ts };
|
|
946
|
+
if (state.lc !== state.ts) {
|
|
947
|
+
entry.lc = state.lc;
|
|
948
|
+
}
|
|
949
|
+
return entry;
|
|
950
|
+
}
|
|
951
|
+
async getStates(ids) {
|
|
952
|
+
const states = [];
|
|
953
|
+
for (const id of ids) {
|
|
954
|
+
try {
|
|
955
|
+
if (id.includes('*')) {
|
|
956
|
+
// Wildcard pattern: expand to all matching states.
|
|
957
|
+
const matches = await this.adapter.getForeignStatesAsync(id, { user: this.defaultUser });
|
|
958
|
+
for (const matchId of Object.keys(matches || {})) {
|
|
959
|
+
states.push(McpServer.stateEntry(matchId, matches[matchId]));
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
else {
|
|
963
|
+
const state = await this.adapter.getForeignStateAsync(id, { user: this.defaultUser });
|
|
964
|
+
states.push(McpServer.stateEntry(id, state));
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
catch (e) {
|
|
968
|
+
states.push({
|
|
969
|
+
id,
|
|
970
|
+
value: null,
|
|
971
|
+
ack: false,
|
|
972
|
+
ts: Date.now(),
|
|
973
|
+
error: e instanceof Error ? e.message : String(e),
|
|
974
|
+
});
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
return states;
|
|
978
|
+
}
|
|
979
|
+
/** Write multiple states; failures of single states are reported per item and do not abort the rest. */
|
|
980
|
+
async setStates(items) {
|
|
981
|
+
const results = [];
|
|
982
|
+
for (const item of items) {
|
|
983
|
+
try {
|
|
984
|
+
const written = await this.setState(item.id, item.value, item.ack ?? false);
|
|
985
|
+
results.push({ id: item.id, value: written });
|
|
986
|
+
}
|
|
987
|
+
catch (e) {
|
|
988
|
+
results.push({ id: item.id, error: e instanceof Error ? e.message : String(e) });
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
return results;
|
|
992
|
+
}
|
|
993
|
+
/**
|
|
994
|
+
* Parse the raw log-file lines returned by the host `getLogs` into structured entries.
|
|
995
|
+
*
|
|
996
|
+
* The host replies with an array of raw strings (the file size is appended as the last, numeric
|
|
997
|
+
* element). Lines carry ANSI color codes and look like
|
|
998
|
+
* `2026-06-12 11:46:39.802 - error: hm-rpc.0 (1234) Init not possible…`. This mirrors the admin
|
|
999
|
+
* `LogsWorker` parsing: strip the color codes, then split timestamp / level / rest, and treat lines
|
|
1000
|
+
* without a leading timestamp as continuations (e.g. stack traces) of the previous entry.
|
|
1001
|
+
*/
|
|
1002
|
+
parseLogLines(rawLines) {
|
|
1003
|
+
const entries = [];
|
|
1004
|
+
for (const raw of rawLines) {
|
|
1005
|
+
if (typeof raw !== 'string') {
|
|
1006
|
+
continue;
|
|
1007
|
+
}
|
|
1008
|
+
// Also strip the trailing CR: the host splits the CRLF log file on \n only, leaving a \r
|
|
1009
|
+
// that would otherwise break the `$` anchor of the line regex below.
|
|
1010
|
+
const clean = raw.replace(/\x1b\[\d+m/g, '').replace(/\r$/, ''); // eslint-disable-line no-control-regex
|
|
1011
|
+
const match = clean.match(/^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3})\s+-\s+(silly|debug|info|warn|error):\s*(.*)$/);
|
|
1012
|
+
if (match) {
|
|
1013
|
+
const rest = match[3];
|
|
1014
|
+
const fromMatch = rest.match(/^(host\.[^\s]+|[-\w]+\.\d+)/);
|
|
1015
|
+
entries.push({
|
|
1016
|
+
ts: new Date(match[1].replace(' ', 'T')).getTime(),
|
|
1017
|
+
level: match[2],
|
|
1018
|
+
source: fromMatch ? fromMatch[1] : '',
|
|
1019
|
+
message: rest,
|
|
1020
|
+
});
|
|
1021
|
+
}
|
|
1022
|
+
else if (entries.length && clean.trim() && !/^\d+$/.test(clean.trim())) {
|
|
1023
|
+
// Continuation line (e.g. a stack trace). The trailing file-size element is a bare number,
|
|
1024
|
+
// which the `\d+` guard skips.
|
|
1025
|
+
entries[entries.length - 1].message += `\n${clean}`;
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
1028
|
+
return entries;
|
|
1029
|
+
}
|
|
1030
|
+
getLogs(params) {
|
|
1031
|
+
const limit = Math.max(1, params.limit || 200);
|
|
1032
|
+
const filtering = !!((params.level && params.level.length) || params.adapter || params.from_ts !== undefined);
|
|
1033
|
+
// The host `getLogs` returns the tail of the log file (≈150 bytes per requested line). When a
|
|
1034
|
+
// filter is active, scan a much larger window so error/warn lines are not pushed out by a flood
|
|
1035
|
+
// of debug lines from a chatty adapter.
|
|
1036
|
+
const fetchLines = filtering ? Math.min(Math.max(limit * 10, 3000), 20000) : limit;
|
|
1037
|
+
return new Promise((resolve, reject) => {
|
|
1038
|
+
// The controller expects the line count as the plain message (a number) and replies with an
|
|
1039
|
+
// ARRAY of raw string lines — NOT an object with a `list` property.
|
|
1040
|
+
this.adapter.sendToHost(this.adapter.host || null, 'getLogs', fetchLines, (result) => {
|
|
1041
|
+
const rawList = Array.isArray(result) ? result : result?.list;
|
|
1042
|
+
if (!Array.isArray(rawList)) {
|
|
1043
|
+
if (result && result.error) {
|
|
1044
|
+
reject(new Error(result.error));
|
|
1045
|
+
}
|
|
1046
|
+
else {
|
|
1047
|
+
resolve([]);
|
|
1048
|
+
}
|
|
1049
|
+
return;
|
|
1050
|
+
}
|
|
1051
|
+
let logs = this.parseLogLines(rawList);
|
|
1052
|
+
if (params.level && params.level.length) {
|
|
1053
|
+
logs = logs.filter(log => params.level.includes(log.level));
|
|
1054
|
+
}
|
|
1055
|
+
if (params.from_ts !== undefined) {
|
|
1056
|
+
logs = logs.filter(log => log.ts >= params.from_ts);
|
|
1057
|
+
}
|
|
1058
|
+
if (params.adapter) {
|
|
1059
|
+
const adapter = params.adapter;
|
|
1060
|
+
logs = logs.filter(log => log.source === adapter || log.source.startsWith(`${adapter}.`));
|
|
1061
|
+
}
|
|
1062
|
+
// Return the most recent `limit` matching lines.
|
|
1063
|
+
if (logs.length > limit) {
|
|
1064
|
+
logs = logs.slice(logs.length - limit);
|
|
1065
|
+
}
|
|
1066
|
+
resolve(logs.map(log => ({
|
|
1067
|
+
ts: log.ts,
|
|
1068
|
+
level: log.level,
|
|
1069
|
+
source: log.source,
|
|
1070
|
+
message: log.message,
|
|
1071
|
+
host: this.adapter.host,
|
|
1072
|
+
})));
|
|
1073
|
+
});
|
|
1074
|
+
});
|
|
1075
|
+
}
|
|
1076
|
+
/**
|
|
1077
|
+
* Diagnose whether a network device/service is reachable: an ICMP ping to `host` and, if `port`
|
|
1078
|
+
* is given, a TCP connect to that port. Used to investigate adapter connection errors.
|
|
1079
|
+
*/
|
|
1080
|
+
async pingHost(params) {
|
|
1081
|
+
const host = (params.host || '').trim();
|
|
1082
|
+
// Only allow plausible IP/hostname characters — and never a leading "-" — so the value can't be
|
|
1083
|
+
// injected as an option to the `ping` executable.
|
|
1084
|
+
if (!host || host.startsWith('-') || !/^[a-zA-Z0-9._:-]+$/.test(host)) {
|
|
1085
|
+
throw new Error(`Invalid host: ${params.host}`);
|
|
1086
|
+
}
|
|
1087
|
+
const count = Math.min(Math.max(params.count || 2, 1), 10);
|
|
1088
|
+
const timeout = Math.min(Math.max(params.timeout || 2000, 100), 20000);
|
|
1089
|
+
this.adapter.log.debug(`[ping_host] request host="${host}" port=${params.port ?? '-'} count=${count} timeout=${timeout}ms platform=${process.platform}`);
|
|
1090
|
+
const resolvedIp = await new Promise(resolve => node_dns_1.default.lookup(host, (err, address) => resolve(err ? undefined : address)));
|
|
1091
|
+
this.adapter.log.debug(`[ping_host] dns.lookup("${host}") -> ${resolvedIp ?? '(no resolution)'}`);
|
|
1092
|
+
const icmp = await this.icmpPing(host, count, timeout);
|
|
1093
|
+
const result = {
|
|
1094
|
+
host,
|
|
1095
|
+
...(resolvedIp && resolvedIp !== host ? { resolved_ip: resolvedIp } : {}),
|
|
1096
|
+
icmp,
|
|
1097
|
+
};
|
|
1098
|
+
if (params.port !== undefined) {
|
|
1099
|
+
// Explicit port requested -> probe exactly that one.
|
|
1100
|
+
result.tcp = await this.tcpProbe(host, params.port, timeout);
|
|
1101
|
+
}
|
|
1102
|
+
else if (!icmp.reachable) {
|
|
1103
|
+
// ICMP did not confirm reachability — either the `ping` binary is missing (e.g. not installed
|
|
1104
|
+
// in the container) or ICMP is blocked/filtered by a firewall. Fall back to TCP connects on
|
|
1105
|
+
// common ports so a host that simply ignores ping (very common for routers) isn't wrongly
|
|
1106
|
+
// reported as "down".
|
|
1107
|
+
const tcpFallback = await this.tcpReachable(host, timeout);
|
|
1108
|
+
result.tcp_fallback = tcpFallback;
|
|
1109
|
+
}
|
|
1110
|
+
// Overall verdict: reachable if ICMP answered OR a TCP fallback port accepted the connection.
|
|
1111
|
+
const fb = result.tcp_fallback;
|
|
1112
|
+
result.reachable = !!icmp.reachable || !!fb?.reachable;
|
|
1113
|
+
// If ICMP could not be used because `ping` is not installed, surface the recommendation at the
|
|
1114
|
+
// top level so the assistant relays it to the user (and the verdict is based on TCP only).
|
|
1115
|
+
if (icmp.unavailable && typeof icmp.recommendation === 'string') {
|
|
1116
|
+
result.method = 'tcp';
|
|
1117
|
+
result.note = icmp.recommendation;
|
|
1118
|
+
}
|
|
1119
|
+
this.adapter.log.debug(`[ping_host] result: ${JSON.stringify(result)}`);
|
|
1120
|
+
return result;
|
|
1121
|
+
}
|
|
1122
|
+
/** ICMP ping via the OS `ping` command (no elevated privileges needed). */
|
|
1123
|
+
icmpPing(host, count, timeout) {
|
|
1124
|
+
const isWin = process.platform === 'win32';
|
|
1125
|
+
const bin = this.resolvePingBinary();
|
|
1126
|
+
// Windows: -n count, -w timeout(ms per reply). Unix: -c count, -W timeout(s per reply).
|
|
1127
|
+
const args = isWin
|
|
1128
|
+
? ['-n', String(count), '-w', String(timeout), host]
|
|
1129
|
+
: ['-c', String(count), '-W', String(Math.max(1, Math.round(timeout / 1000))), host];
|
|
1130
|
+
this.adapter.log.debug(`[ping_host] icmp exec: ${bin} ${args.join(' ')}`);
|
|
1131
|
+
return new Promise(resolve => {
|
|
1132
|
+
(0, node_child_process_1.execFile)(bin, args, { timeout: timeout * count + 3000, windowsHide: true }, (err, stdout, stderr) => {
|
|
1133
|
+
const out = `${stdout || ''}${stderr || ''}`;
|
|
1134
|
+
const errCode = err ? `${err.code ?? err.message}` : 'none';
|
|
1135
|
+
this.adapter.log.debug(`[ping_host] icmp exitError=${errCode} raw output:\n${out}`);
|
|
1136
|
+
// ENOENT = the `ping` executable was not found at all. That is NOT "host unreachable";
|
|
1137
|
+
// report it explicitly so the caller can rely on the TCP fallback instead.
|
|
1138
|
+
if (err && err.code === 'ENOENT') {
|
|
1139
|
+
const install = this.suggestPingInstall();
|
|
1140
|
+
this.adapter.log.warn(`[ping_host] "ping" executable not found. ICMP check skipped — using TCP fallback. To enable ICMP, install it: ${install.command}`);
|
|
1141
|
+
resolve({
|
|
1142
|
+
reachable: false,
|
|
1143
|
+
unavailable: true,
|
|
1144
|
+
error: 'ping executable not found on this host',
|
|
1145
|
+
recommendation: `ICMP ping is not available because the "ping" command is not installed on the ioBroker host. Reachability was therefore determined via TCP. To enable real ICMP ping, install it on the host (${install.os}): ${install.command}`,
|
|
1146
|
+
install_command: install.command,
|
|
1147
|
+
});
|
|
1148
|
+
return;
|
|
1149
|
+
}
|
|
1150
|
+
// Number of received replies (Windows "Received = N", Unix "N received").
|
|
1151
|
+
const received = isWin
|
|
1152
|
+
? Number(out.match(/Received = (\d+)/)?.[1] ?? out.match(/Empfangen = (\d+)/)?.[1] ?? 0)
|
|
1153
|
+
: Number(out.match(/(\d+) (?:packets )?received/)?.[1] ?? 0);
|
|
1154
|
+
// Average round-trip time, if reported.
|
|
1155
|
+
const avg = isWin
|
|
1156
|
+
? (out.match(/Average = (\d+)ms/)?.[1] ?? out.match(/Mittelwert = (\d+)ms/)?.[1])
|
|
1157
|
+
: out.match(/=\s*[\d.]+\/([\d.]+)\//)?.[1];
|
|
1158
|
+
this.adapter.log.debug(`[ping_host] icmp parsed: received=${received} (matchedWin=${isWin}) avg=${avg ?? '-'} -> reachable=${received > 0}`);
|
|
1159
|
+
resolve({
|
|
1160
|
+
reachable: received > 0,
|
|
1161
|
+
sent: count,
|
|
1162
|
+
received,
|
|
1163
|
+
...(avg ? { avg_ms: Math.round(parseFloat(avg)) } : {}),
|
|
1164
|
+
});
|
|
1165
|
+
});
|
|
1166
|
+
});
|
|
1167
|
+
}
|
|
1168
|
+
/**
|
|
1169
|
+
* Locate the `ping` executable. On Linux the adapter process sometimes runs with a minimal `PATH`
|
|
1170
|
+
* (e.g. only `/usr/bin`) while `ping` lives in `/bin` or `/sbin`, which makes a bare `ping` fail with
|
|
1171
|
+
* ENOENT. Probe the common absolute locations and fall back to the bare name otherwise.
|
|
1172
|
+
*/
|
|
1173
|
+
resolvePingBinary() {
|
|
1174
|
+
if (process.platform === 'win32') {
|
|
1175
|
+
return 'ping';
|
|
1176
|
+
}
|
|
1177
|
+
for (const candidate of ['/bin/ping', '/usr/bin/ping', '/sbin/ping', '/usr/sbin/ping']) {
|
|
1178
|
+
try {
|
|
1179
|
+
if (node_fs_1.default.existsSync(candidate)) {
|
|
1180
|
+
return candidate;
|
|
1181
|
+
}
|
|
1182
|
+
}
|
|
1183
|
+
catch {
|
|
1184
|
+
// ignore and try the next candidate
|
|
1185
|
+
}
|
|
1186
|
+
}
|
|
1187
|
+
return 'ping';
|
|
1188
|
+
}
|
|
1189
|
+
/**
|
|
1190
|
+
* Build a distro-appropriate command to install the `ping` tool, used in the recommendation when the
|
|
1191
|
+
* binary is missing. The distro is detected from the package manager / release files present on disk.
|
|
1192
|
+
*/
|
|
1193
|
+
suggestPingInstall() {
|
|
1194
|
+
if (process.platform === 'win32') {
|
|
1195
|
+
return { os: 'Windows', command: 'ping is part of Windows — no installation needed' };
|
|
1196
|
+
}
|
|
1197
|
+
if (process.platform === 'darwin') {
|
|
1198
|
+
return { os: 'macOS', command: 'ping is part of macOS — no installation needed' };
|
|
1199
|
+
}
|
|
1200
|
+
const exists = (p) => {
|
|
1201
|
+
try {
|
|
1202
|
+
return node_fs_1.default.existsSync(p);
|
|
1203
|
+
}
|
|
1204
|
+
catch {
|
|
1205
|
+
return false;
|
|
1206
|
+
}
|
|
1207
|
+
};
|
|
1208
|
+
if (exists('/etc/alpine-release') || exists('/sbin/apk')) {
|
|
1209
|
+
// Common on the official ioBroker Docker image (Alpine based).
|
|
1210
|
+
return { os: 'Alpine Linux', command: 'apk add --no-cache iputils-ping' };
|
|
1211
|
+
}
|
|
1212
|
+
if (exists('/etc/debian_version') || exists('/usr/bin/apt-get')) {
|
|
1213
|
+
return { os: 'Debian/Ubuntu', command: 'sudo apt-get update && sudo apt-get install -y iputils-ping' };
|
|
1214
|
+
}
|
|
1215
|
+
if (exists('/usr/bin/dnf')) {
|
|
1216
|
+
return { os: 'Fedora/RHEL', command: 'sudo dnf install -y iputils' };
|
|
1217
|
+
}
|
|
1218
|
+
if (exists('/usr/bin/yum')) {
|
|
1219
|
+
return { os: 'CentOS/RHEL', command: 'sudo yum install -y iputils' };
|
|
1220
|
+
}
|
|
1221
|
+
if (exists('/etc/arch-release') || exists('/usr/bin/pacman')) {
|
|
1222
|
+
return { os: 'Arch Linux', command: 'sudo pacman -S --noconfirm iputils' };
|
|
1223
|
+
}
|
|
1224
|
+
return { os: 'Linux', command: 'install the "iputils-ping" (or "iputils") package via your package manager' };
|
|
1225
|
+
}
|
|
1226
|
+
/**
|
|
1227
|
+
* Reachability fallback when ICMP is unavailable/blocked: try a TCP connect to a handful of ports
|
|
1228
|
+
* that are commonly open on routers/devices. The host counts as reachable as soon as one port either
|
|
1229
|
+
* accepts the connection or actively refuses it (a RST also proves the host is up).
|
|
1230
|
+
*/
|
|
1231
|
+
async tcpReachable(host, timeout) {
|
|
1232
|
+
const ports = [80, 443, 53, 22, 7547, 8080];
|
|
1233
|
+
const perPort = Math.min(Math.max(Math.round(timeout / 2), 500), 3000);
|
|
1234
|
+
this.adapter.log.debug(`[ping_host] tcp fallback on ports ${ports.join(',')} (perPort=${perPort}ms)`);
|
|
1235
|
+
for (const port of ports) {
|
|
1236
|
+
const probe = await this.tcpProbe(host, port, perPort);
|
|
1237
|
+
// "open" = accepted; a "connection refused" error proves the host is alive but the port closed.
|
|
1238
|
+
const refused = typeof probe.error === 'string' && /refused|ECONNREFUSED/i.test(probe.error);
|
|
1239
|
+
if (probe.open || refused) {
|
|
1240
|
+
return { reachable: true, port, open: !!probe.open, refused, latency_ms: probe.latency_ms };
|
|
1241
|
+
}
|
|
1242
|
+
}
|
|
1243
|
+
return { reachable: false, tried_ports: ports };
|
|
1244
|
+
}
|
|
1245
|
+
/** TCP connect probe — tests whether a specific service port accepts connections. */
|
|
1246
|
+
tcpProbe(host, port, timeout) {
|
|
1247
|
+
this.adapter.log.debug(`[ping_host] tcp connect ${host}:${port} (timeout=${timeout}ms)`);
|
|
1248
|
+
return new Promise(resolve => {
|
|
1249
|
+
const start = Date.now();
|
|
1250
|
+
const socket = new node_net_1.default.Socket();
|
|
1251
|
+
let settled = false;
|
|
1252
|
+
const done = (open, error) => {
|
|
1253
|
+
if (settled) {
|
|
1254
|
+
return;
|
|
1255
|
+
}
|
|
1256
|
+
settled = true;
|
|
1257
|
+
socket.destroy();
|
|
1258
|
+
const probe = { port, open, latency_ms: Date.now() - start, ...(error ? { error } : {}) };
|
|
1259
|
+
this.adapter.log.debug(`[ping_host] tcp result: ${JSON.stringify(probe)}`);
|
|
1260
|
+
resolve(probe);
|
|
1261
|
+
};
|
|
1262
|
+
socket.setTimeout(timeout);
|
|
1263
|
+
socket.once('connect', () => done(true));
|
|
1264
|
+
socket.once('timeout', () => done(false, 'timeout'));
|
|
1265
|
+
socket.once('error', (e) => done(false, e.message));
|
|
1266
|
+
socket.connect(port, host);
|
|
1267
|
+
});
|
|
1268
|
+
}
|
|
1269
|
+
async getSystemInfo() {
|
|
1270
|
+
const hostObj = await this.adapter.getForeignObjectAsync(`system.host.${this.adapter.host}`, {
|
|
1271
|
+
user: this.defaultUser,
|
|
1272
|
+
});
|
|
1273
|
+
const jsControllerVersion = hostObj?.common?.installedVersion || 'unknown';
|
|
1274
|
+
const totalMem = Math.round(node_os_1.default.totalmem() / (1024 * 1024));
|
|
1275
|
+
const freeMem = Math.round(node_os_1.default.freemem() / (1024 * 1024));
|
|
1276
|
+
const instanceObjs = await this.adapter.getForeignObjectsAsync('system.adapter.*', 'instance', {
|
|
1277
|
+
user: this.defaultUser,
|
|
1278
|
+
});
|
|
1279
|
+
return {
|
|
1280
|
+
js_controller: jsControllerVersion,
|
|
1281
|
+
hostname: node_os_1.default.hostname(),
|
|
1282
|
+
platform: node_os_1.default.platform(),
|
|
1283
|
+
node: process.version.substring(1),
|
|
1284
|
+
cpu_load: parseFloat((node_os_1.default.loadavg()[0] || 0).toFixed(2)),
|
|
1285
|
+
mem: { total_mb: totalMem, used_mb: totalMem - freeMem },
|
|
1286
|
+
uptime_sec: Math.round(node_os_1.default.uptime()),
|
|
1287
|
+
instances: Object.keys(instanceObjs || {}).length,
|
|
1288
|
+
};
|
|
1289
|
+
}
|
|
1290
|
+
async searchObjects(params) {
|
|
1291
|
+
const { query = '', type, role, room, adapter, limit = 100 } = params;
|
|
1292
|
+
const result = await this.adapter.getObjectListAsync({
|
|
1293
|
+
startkey: '',
|
|
1294
|
+
endkey: '\u9999',
|
|
1295
|
+
}, { sorted: true, user: this.defaultUser });
|
|
1296
|
+
const allObjects = result.rows;
|
|
1297
|
+
const roomMembers = room ? await this.getEnumMembers('enum.rooms.', room) : null;
|
|
1298
|
+
const needle = query.toLowerCase();
|
|
1299
|
+
const results = [];
|
|
1300
|
+
for (const obj of allObjects) {
|
|
1301
|
+
const o = obj.value;
|
|
1302
|
+
const name = this.getName(o?.common?.name);
|
|
1303
|
+
if (needle && !obj.value._id.toLowerCase().includes(needle) && !name.toLowerCase().includes(needle)) {
|
|
1304
|
+
continue;
|
|
1305
|
+
}
|
|
1306
|
+
if (type && o?.type !== type) {
|
|
1307
|
+
continue;
|
|
1308
|
+
}
|
|
1309
|
+
if (role && o?.common?.role !== role) {
|
|
1310
|
+
continue;
|
|
1311
|
+
}
|
|
1312
|
+
if (roomMembers && !roomMembers.includes(obj.value._id)) {
|
|
1313
|
+
continue;
|
|
1314
|
+
}
|
|
1315
|
+
const sourceAdapter = obj.value._id.match(/^([^.]+\.\d+)/)?.[1] || '';
|
|
1316
|
+
if (adapter && sourceAdapter !== adapter) {
|
|
1317
|
+
continue;
|
|
1318
|
+
}
|
|
1319
|
+
results.push({
|
|
1320
|
+
id: obj.value._id,
|
|
1321
|
+
type: o?.type || 'state',
|
|
1322
|
+
role: o?.common?.role || '',
|
|
1323
|
+
name,
|
|
1324
|
+
adapter: sourceAdapter,
|
|
1325
|
+
});
|
|
1326
|
+
if (results.length >= limit) {
|
|
1327
|
+
break;
|
|
1328
|
+
}
|
|
1329
|
+
}
|
|
1330
|
+
return results;
|
|
1331
|
+
}
|
|
1332
|
+
async listDevices(params) {
|
|
1333
|
+
const lang = params.language || this.language;
|
|
1334
|
+
const rooms = await (0, devices_1.getAiFriendlyStructure)(this.adapter, lang, { user: this.defaultUser });
|
|
1335
|
+
if (params.room) {
|
|
1336
|
+
const needle = params.room.toLowerCase();
|
|
1337
|
+
return rooms.filter(r => r.roomName.toLowerCase() === needle);
|
|
1338
|
+
}
|
|
1339
|
+
return rooms;
|
|
1340
|
+
}
|
|
1341
|
+
async historyQuery(params) {
|
|
1342
|
+
const options = {
|
|
1343
|
+
aggregate: AGG_MAP[params.agg || 'raw'] || 'none',
|
|
1344
|
+
count: params.limit ?? 1000,
|
|
1345
|
+
limit: params.limit ?? 1000,
|
|
1346
|
+
};
|
|
1347
|
+
if (params.from) {
|
|
1348
|
+
options.start = new Date(params.from).getTime();
|
|
1349
|
+
}
|
|
1350
|
+
if (params.to) {
|
|
1351
|
+
options.end = new Date(params.to).getTime();
|
|
1352
|
+
}
|
|
1353
|
+
if (params.percentile !== undefined) {
|
|
1354
|
+
options.percentile = params.percentile;
|
|
1355
|
+
}
|
|
1356
|
+
if (params.quantile !== undefined) {
|
|
1357
|
+
options.quantile = params.quantile;
|
|
1358
|
+
}
|
|
1359
|
+
const step = params.interval ? this.parseInterval(params.interval) : undefined;
|
|
1360
|
+
if (step) {
|
|
1361
|
+
options.step = step;
|
|
1362
|
+
}
|
|
1363
|
+
// GetHistoryOptions has no typed `user` field, but the controller honors it for ACL checks.
|
|
1364
|
+
options.user = this.defaultUser;
|
|
1365
|
+
const res = await this.adapter.getHistoryAsync(params.id, options);
|
|
1366
|
+
return { id: params.id, values: res?.result || [] };
|
|
1367
|
+
}
|
|
1368
|
+
async listInstances() {
|
|
1369
|
+
const objs = await this.adapter.getForeignObjectsAsync('system.adapter.*', 'instance', {
|
|
1370
|
+
user: this.defaultUser,
|
|
1371
|
+
});
|
|
1372
|
+
const result = [];
|
|
1373
|
+
for (const [fullId, obj] of Object.entries(objs || {})) {
|
|
1374
|
+
const id = fullId.replace('system.adapter.', '');
|
|
1375
|
+
const aliveState = await this.adapter.getForeignStateAsync(`${fullId}.alive`, { user: this.defaultUser });
|
|
1376
|
+
const connState = await this.adapter.getForeignStateAsync(`${fullId}.connected`, {
|
|
1377
|
+
user: this.defaultUser,
|
|
1378
|
+
});
|
|
1379
|
+
result.push({
|
|
1380
|
+
id,
|
|
1381
|
+
enabled: !!obj?.common?.enabled,
|
|
1382
|
+
alive: !!aliveState?.val,
|
|
1383
|
+
connected: connState ? !!connState.val : null,
|
|
1384
|
+
version: obj?.common?.version || '',
|
|
1385
|
+
title: this.getName(obj?.common?.titleLang || obj?.common?.title),
|
|
1386
|
+
});
|
|
1387
|
+
}
|
|
1388
|
+
return result;
|
|
1389
|
+
}
|
|
1390
|
+
/** List installed adapters (system.adapter.<name> objects, no instances). */
|
|
1391
|
+
async listAdapters(language) {
|
|
1392
|
+
const lang = language || this.language;
|
|
1393
|
+
const view = await this.adapter.getObjectViewAsync('system', 'adapter', { startkey: 'system.adapter.', endkey: 'system.adapter.香' }, { user: this.defaultUser });
|
|
1394
|
+
return view.rows.map(row => {
|
|
1395
|
+
const common = (row.value?.common || {});
|
|
1396
|
+
return {
|
|
1397
|
+
name: common.name || row.id.replace('system.adapter.', ''),
|
|
1398
|
+
version: common.version || '',
|
|
1399
|
+
title: getText(common.titleLang || common.title, lang),
|
|
1400
|
+
description: getText(common.desc, lang),
|
|
1401
|
+
keywords: common.keywords || [],
|
|
1402
|
+
mode: common.mode || '',
|
|
1403
|
+
};
|
|
1404
|
+
});
|
|
1405
|
+
}
|
|
1406
|
+
/**
|
|
1407
|
+
* Search the adapter repository (all installable adapters) by keyword. Reads the already-downloaded
|
|
1408
|
+
* repository object `system.repositories` — fast, no network — and matches against name, title,
|
|
1409
|
+
* description and keywords, with an optional category `type` filter and an `installed` flag.
|
|
1410
|
+
*/
|
|
1411
|
+
async searchAdapterRepository(params) {
|
|
1412
|
+
const lang = params.language || this.language;
|
|
1413
|
+
const needle = (params.query || '').toLowerCase();
|
|
1414
|
+
const limit = params.limit ?? 20;
|
|
1415
|
+
const reposObj = await this.adapter.getForeignObjectAsync('system.repositories', { user: this.defaultUser });
|
|
1416
|
+
const repositories = (reposObj?.native?.repositories || {});
|
|
1417
|
+
// Prefer the active repository; fall back to the first repository that has content.
|
|
1418
|
+
const sysCfg = await this.adapter.getForeignObjectAsync('system.config', { user: this.defaultUser });
|
|
1419
|
+
const active = sysCfg?.common?.activeRepo;
|
|
1420
|
+
const activeNames = Array.isArray(active) ? active : active ? [active] : [];
|
|
1421
|
+
// Merge the JSON of ALL active repositories (or all repositories if none is marked active). An
|
|
1422
|
+
// adapter present in several repos is kept once (first/active repo wins). This matters when the
|
|
1423
|
+
// user has custom repos active next to the default one (their adapters are not in stable).
|
|
1424
|
+
const activeWithJson = activeNames.filter(name => repositories[name]?.json);
|
|
1425
|
+
const sources = (activeWithJson.length ? activeWithJson.map(name => repositories[name]) : Object.values(repositories))
|
|
1426
|
+
.map(repo => repo?.json)
|
|
1427
|
+
.filter((j) => !!j);
|
|
1428
|
+
const json = {};
|
|
1429
|
+
for (const src of sources) {
|
|
1430
|
+
for (const key of Object.keys(src)) {
|
|
1431
|
+
if (!(key in json)) {
|
|
1432
|
+
json[key] = src[key];
|
|
1433
|
+
}
|
|
1434
|
+
}
|
|
1435
|
+
}
|
|
1436
|
+
if (!Object.keys(json).length) {
|
|
1437
|
+
return [];
|
|
1438
|
+
}
|
|
1439
|
+
// Determine which adapters are already installed.
|
|
1440
|
+
const installedView = await this.adapter.getObjectViewAsync('system', 'adapter', { startkey: 'system.adapter.', endkey: 'system.adapter.香' }, { user: this.defaultUser });
|
|
1441
|
+
const installed = new Set(installedView.rows.map(row => row.value?.common?.name || row.id.replace('system.adapter.', '')));
|
|
1442
|
+
const results = [];
|
|
1443
|
+
for (const [name, entry] of Object.entries(json)) {
|
|
1444
|
+
if (name.startsWith('_')) {
|
|
1445
|
+
continue; // skip _repoInfo and similar meta keys
|
|
1446
|
+
}
|
|
1447
|
+
const e = entry;
|
|
1448
|
+
const title = getText(e.titleLang || e.title, lang);
|
|
1449
|
+
const desc = getText(e.desc, lang);
|
|
1450
|
+
const keywords = Array.isArray(e.keywords) ? e.keywords : [];
|
|
1451
|
+
const type = e.type || '';
|
|
1452
|
+
if (params.type && type !== params.type) {
|
|
1453
|
+
continue;
|
|
1454
|
+
}
|
|
1455
|
+
const isInstalled = installed.has(name);
|
|
1456
|
+
if (params.onlyNotInstalled && isInstalled) {
|
|
1457
|
+
continue;
|
|
1458
|
+
}
|
|
1459
|
+
if (needle && !`${name} ${title} ${desc} ${keywords.join(' ')}`.toLowerCase().includes(needle)) {
|
|
1460
|
+
continue;
|
|
1461
|
+
}
|
|
1462
|
+
results.push({
|
|
1463
|
+
name,
|
|
1464
|
+
title,
|
|
1465
|
+
description: desc,
|
|
1466
|
+
keywords,
|
|
1467
|
+
type,
|
|
1468
|
+
version: e.version || '',
|
|
1469
|
+
installed: isInstalled,
|
|
1470
|
+
});
|
|
1471
|
+
if (results.length >= limit) {
|
|
1472
|
+
break;
|
|
1473
|
+
}
|
|
1474
|
+
}
|
|
1475
|
+
return results;
|
|
1476
|
+
}
|
|
1477
|
+
async listHosts() {
|
|
1478
|
+
const objs = await this.adapter.getForeignObjectsAsync('system.host.*', 'host', { user: this.defaultUser });
|
|
1479
|
+
const result = [];
|
|
1480
|
+
for (const [fullId, obj] of Object.entries(objs || {})) {
|
|
1481
|
+
const aliveState = await this.adapter.getForeignStateAsync(`${fullId}.alive`, { user: this.defaultUser });
|
|
1482
|
+
result.push({
|
|
1483
|
+
id: fullId.replace('system.host.', ''),
|
|
1484
|
+
alive: !!aliveState?.val,
|
|
1485
|
+
js_controller: obj?.common?.installedVersion || '',
|
|
1486
|
+
platform: obj?.native?.os?.platform || '',
|
|
1487
|
+
});
|
|
1488
|
+
}
|
|
1489
|
+
return result;
|
|
1490
|
+
}
|
|
1491
|
+
/**
|
|
1492
|
+
* Read the rooms/functions enums with localized names and details about each member object
|
|
1493
|
+
* (ported from the ioBroker n8n node's `readIobEnums`).
|
|
1494
|
+
*/
|
|
1495
|
+
async readEnums(type, language, withIcons) {
|
|
1496
|
+
const enums = await this.adapter.getObjectViewAsync('system', 'enum', { startkey: `enum.${type}.`, endkey: `enum.${type}.香` }, { user: this.defaultUser });
|
|
1497
|
+
const result = [];
|
|
1498
|
+
// Cache member objects so a member shared by several enums is read only once.
|
|
1499
|
+
const cache = {};
|
|
1500
|
+
for (const row of enums.rows) {
|
|
1501
|
+
const enumObj = row.value;
|
|
1502
|
+
const common = (enumObj.common || {});
|
|
1503
|
+
const oneEnum = {
|
|
1504
|
+
id: enumObj._id,
|
|
1505
|
+
name: (language ? getText(common.name, language) : common.name) || enumObj._id.split('.').pop() || '',
|
|
1506
|
+
color: common.color,
|
|
1507
|
+
icon: withIcons ? common.icon : undefined,
|
|
1508
|
+
items: [],
|
|
1509
|
+
};
|
|
1510
|
+
result.push(oneEnum);
|
|
1511
|
+
for (const member of common.members || []) {
|
|
1512
|
+
let obj = cache[member];
|
|
1513
|
+
if (obj === undefined) {
|
|
1514
|
+
try {
|
|
1515
|
+
cache[member] =
|
|
1516
|
+
(await this.adapter.getForeignObjectAsync(member, { user: this.defaultUser })) || false;
|
|
1517
|
+
}
|
|
1518
|
+
catch {
|
|
1519
|
+
cache[member] = false;
|
|
1520
|
+
}
|
|
1521
|
+
obj = cache[member];
|
|
1522
|
+
}
|
|
1523
|
+
if (obj) {
|
|
1524
|
+
const c = (obj.common || {});
|
|
1525
|
+
oneEnum.items.push({
|
|
1526
|
+
id: member,
|
|
1527
|
+
type: obj.type,
|
|
1528
|
+
name: (language ? getText(c.name, language) : c.name) || member.split('.').pop() || '',
|
|
1529
|
+
color: c.color,
|
|
1530
|
+
icon: withIcons ? c.icon : undefined,
|
|
1531
|
+
stateType: c.type,
|
|
1532
|
+
min: c.min,
|
|
1533
|
+
max: c.max,
|
|
1534
|
+
unit: c.unit,
|
|
1535
|
+
role: c.role,
|
|
1536
|
+
step: c.step,
|
|
1537
|
+
});
|
|
1538
|
+
}
|
|
1539
|
+
}
|
|
1540
|
+
}
|
|
1541
|
+
return result;
|
|
1542
|
+
}
|
|
1543
|
+
/** Write a state, coercing the value to the state's declared type (boolean/number/string). */
|
|
1544
|
+
async setState(id, value, ack) {
|
|
1545
|
+
const obj = await this.adapter.getForeignObjectAsync(id, { user: this.defaultUser });
|
|
1546
|
+
const stateType = obj?.common?.type;
|
|
1547
|
+
const coerced = McpServer.coerceValue(value, stateType);
|
|
1548
|
+
await this.adapter.setForeignStateAsync(id, coerced, ack, { user: this.defaultUser });
|
|
1549
|
+
return coerced;
|
|
1550
|
+
}
|
|
1551
|
+
/** Coerce an arbitrary value to the given ioBroker state type. */
|
|
1552
|
+
static coerceValue(value, type) {
|
|
1553
|
+
if (value === null || value === undefined) {
|
|
1554
|
+
return value;
|
|
1555
|
+
}
|
|
1556
|
+
if (type === 'number') {
|
|
1557
|
+
if (typeof value === 'number') {
|
|
1558
|
+
return value;
|
|
1559
|
+
}
|
|
1560
|
+
if (typeof value === 'boolean') {
|
|
1561
|
+
return value ? 1 : 0;
|
|
1562
|
+
}
|
|
1563
|
+
if (typeof value === 'string') {
|
|
1564
|
+
const n = parseFloat(value);
|
|
1565
|
+
return isNaN(n) ? value : n;
|
|
1566
|
+
}
|
|
1567
|
+
return value;
|
|
1568
|
+
}
|
|
1569
|
+
if (type === 'boolean') {
|
|
1570
|
+
if (typeof value === 'boolean') {
|
|
1571
|
+
return value;
|
|
1572
|
+
}
|
|
1573
|
+
if (typeof value === 'number') {
|
|
1574
|
+
return !!value;
|
|
1575
|
+
}
|
|
1576
|
+
if (typeof value === 'string') {
|
|
1577
|
+
return value.toLowerCase() === 'true' || value === '1';
|
|
1578
|
+
}
|
|
1579
|
+
return value;
|
|
1580
|
+
}
|
|
1581
|
+
if (type === 'string') {
|
|
1582
|
+
if (typeof value === 'string') {
|
|
1583
|
+
return value;
|
|
1584
|
+
}
|
|
1585
|
+
if (typeof value === 'number' || typeof value === 'boolean') {
|
|
1586
|
+
return value.toString();
|
|
1587
|
+
}
|
|
1588
|
+
return JSON.stringify(value);
|
|
1589
|
+
}
|
|
1590
|
+
return value;
|
|
1591
|
+
}
|
|
1592
|
+
/** Read a file from an adapter file storage. */
|
|
1593
|
+
async readFile(path, base64) {
|
|
1594
|
+
const { adapterName, fileName } = McpServer.parseFilePath(path);
|
|
1595
|
+
const data = await this.adapter.readFileAsync(adapterName, fileName, { user: this.defaultUser });
|
|
1596
|
+
const file = data?.file;
|
|
1597
|
+
const mimeType = data?.mimeType;
|
|
1598
|
+
if (base64 || typeof file !== 'string') {
|
|
1599
|
+
return {
|
|
1600
|
+
path,
|
|
1601
|
+
mimeType,
|
|
1602
|
+
encoding: 'base64',
|
|
1603
|
+
content: Buffer.from(file).toString('base64'),
|
|
1604
|
+
};
|
|
1605
|
+
}
|
|
1606
|
+
return { path, mimeType, encoding: 'utf8', content: file };
|
|
1607
|
+
}
|
|
1608
|
+
/** Write a file to an adapter file storage. */
|
|
1609
|
+
async writeFile(path, content, base64) {
|
|
1610
|
+
const { adapterName, fileName } = McpServer.parseFilePath(path);
|
|
1611
|
+
const data = base64 ? Buffer.from(content, 'base64') : content;
|
|
1612
|
+
await this.adapter.writeFileAsync(adapterName, fileName, data, { user: this.defaultUser });
|
|
1613
|
+
}
|
|
1614
|
+
/** List a directory in an adapter file storage. */
|
|
1615
|
+
async listFiles(path) {
|
|
1616
|
+
const { adapterName, dirName } = McpServer.parseDirPath(path);
|
|
1617
|
+
const entries = await this.adapter.readDirAsync(adapterName, dirName, { user: this.defaultUser });
|
|
1618
|
+
return (entries || []).map(entry => ({
|
|
1619
|
+
file: entry.file,
|
|
1620
|
+
isDir: !!entry.isDir,
|
|
1621
|
+
size: entry.stats?.size,
|
|
1622
|
+
modified: entry.modifiedAt,
|
|
1623
|
+
}));
|
|
1624
|
+
}
|
|
1625
|
+
/** Rename/move a file within the same adapter file storage. */
|
|
1626
|
+
async renameFile(path, newPath) {
|
|
1627
|
+
const from = McpServer.parseFilePath(path);
|
|
1628
|
+
const to = McpServer.parseFilePath(newPath);
|
|
1629
|
+
if (from.adapterName !== to.adapterName) {
|
|
1630
|
+
throw new Error('Renaming across adapter storages is not supported; both paths must share the adapter');
|
|
1631
|
+
}
|
|
1632
|
+
await this.adapter.renameAsync(from.adapterName, from.fileName, to.fileName, { user: this.defaultUser });
|
|
1633
|
+
}
|
|
1634
|
+
/** Create or update an object, merging common/native into an existing object (n8n `setIobObject`). */
|
|
1635
|
+
async setObject(id, obj) {
|
|
1636
|
+
let existing = await this.adapter.getForeignObjectAsync(id, { user: this.defaultUser });
|
|
1637
|
+
if (existing) {
|
|
1638
|
+
if (obj.common) {
|
|
1639
|
+
existing.common = { ...existing.common, ...obj.common };
|
|
1640
|
+
}
|
|
1641
|
+
if (obj.native) {
|
|
1642
|
+
existing.native = { ...existing.native, ...obj.native };
|
|
1643
|
+
}
|
|
1644
|
+
}
|
|
1645
|
+
else {
|
|
1646
|
+
existing = obj;
|
|
1647
|
+
}
|
|
1648
|
+
return this.adapter.setForeignObjectAsync(id, existing, { user: this.defaultUser });
|
|
1649
|
+
}
|
|
1650
|
+
/** Split a file path "<adapter>/<dir>/<file>" into adapter name and file name. */
|
|
1651
|
+
static parseFilePath(path) {
|
|
1652
|
+
const [adapterName, ...rest] = path.replace(/^\//, '').split('/');
|
|
1653
|
+
if (!adapterName) {
|
|
1654
|
+
throw new Error('Path must start with an adapter name, e.g. "vis-2.0/main/vis-views.json"');
|
|
1655
|
+
}
|
|
1656
|
+
const fileName = rest.join('/');
|
|
1657
|
+
if (!fileName) {
|
|
1658
|
+
throw new Error('Path must contain a file name after the adapter, e.g. "vis-2.0/main/vis-views.json"');
|
|
1659
|
+
}
|
|
1660
|
+
return { adapterName, fileName };
|
|
1661
|
+
}
|
|
1662
|
+
/** Split a directory path "<adapter>[/<dir>]" into adapter name and (possibly empty) directory. */
|
|
1663
|
+
static parseDirPath(path) {
|
|
1664
|
+
const [adapterName, ...rest] = path.replace(/^\//, '').replace(/\/$/, '').split('/');
|
|
1665
|
+
if (!adapterName) {
|
|
1666
|
+
throw new Error('Path must start with an adapter name, e.g. "vis-2.0/main" or "0_userdata.0"');
|
|
1667
|
+
}
|
|
1668
|
+
return { adapterName, dirName: rest.join('/') };
|
|
1669
|
+
}
|
|
1670
|
+
/** Delete an object, optionally with all its children. */
|
|
1671
|
+
async deleteObject(id, recursive) {
|
|
1672
|
+
const existing = await this.adapter.getForeignObjectAsync(id, { user: this.defaultUser });
|
|
1673
|
+
if (!existing) {
|
|
1674
|
+
throw new Error(`Object "${id}" does not exist`);
|
|
1675
|
+
}
|
|
1676
|
+
await this.adapter.delForeignObjectAsync(id, { user: this.defaultUser, recursive: !!recursive });
|
|
1677
|
+
}
|
|
1678
|
+
/** Create a new state object; refuses to overwrite an existing object. */
|
|
1679
|
+
async createState(params) {
|
|
1680
|
+
const existing = await this.adapter.getForeignObjectAsync(params.id, { user: this.defaultUser });
|
|
1681
|
+
if (existing) {
|
|
1682
|
+
throw new Error(`Object "${params.id}" already exists - use set_object to modify it`);
|
|
1683
|
+
}
|
|
1684
|
+
const common = {
|
|
1685
|
+
name: params.name || params.id.split('.').pop() || params.id,
|
|
1686
|
+
type: params.type || 'mixed',
|
|
1687
|
+
role: params.role || 'state',
|
|
1688
|
+
read: params.read !== false,
|
|
1689
|
+
write: params.write !== false,
|
|
1690
|
+
};
|
|
1691
|
+
if (params.unit !== undefined) {
|
|
1692
|
+
common.unit = params.unit;
|
|
1693
|
+
}
|
|
1694
|
+
if (params.min !== undefined) {
|
|
1695
|
+
common.min = params.min;
|
|
1696
|
+
}
|
|
1697
|
+
if (params.max !== undefined) {
|
|
1698
|
+
common.max = params.max;
|
|
1699
|
+
}
|
|
1700
|
+
if (params.step !== undefined) {
|
|
1701
|
+
common.step = params.step;
|
|
1702
|
+
}
|
|
1703
|
+
if (params.desc !== undefined) {
|
|
1704
|
+
common.desc = params.desc;
|
|
1705
|
+
}
|
|
1706
|
+
await this.adapter.setForeignObjectAsync(params.id, { type: 'state', common, native: {} }, { user: this.defaultUser });
|
|
1707
|
+
if (params.def !== undefined) {
|
|
1708
|
+
const coerced = McpServer.coerceValue(params.def, common.type);
|
|
1709
|
+
await this.adapter.setForeignStateAsync(params.id, coerced, true, { user: this.defaultUser });
|
|
1710
|
+
}
|
|
1711
|
+
return { id: params.id };
|
|
1712
|
+
}
|
|
1713
|
+
/** Create or update a scene object for the ioBroker "scenes" adapter. */
|
|
1714
|
+
async createScene(params) {
|
|
1715
|
+
const instance = params.instance || 'scene.0';
|
|
1716
|
+
const instanceObj = await this.adapter.getForeignObjectAsync(`system.adapter.${instance}`, {
|
|
1717
|
+
user: this.defaultUser,
|
|
1718
|
+
});
|
|
1719
|
+
if (!instanceObj) {
|
|
1720
|
+
throw new Error(`Scene adapter instance "${instance}" is not installed. Install the ioBroker "scenes" adapter first.`);
|
|
1721
|
+
}
|
|
1722
|
+
// Scene IDs may not contain the characters that are forbidden in ioBroker IDs.
|
|
1723
|
+
const sceneName = params.name.replace(/[\s.*?"'[\]]/g, '_');
|
|
1724
|
+
const id = `${instance}.${sceneName}`;
|
|
1725
|
+
const sceneObj = {
|
|
1726
|
+
type: 'state',
|
|
1727
|
+
common: {
|
|
1728
|
+
name: params.name,
|
|
1729
|
+
type: 'boolean',
|
|
1730
|
+
role: 'scene.state',
|
|
1731
|
+
desc: params.description || '',
|
|
1732
|
+
read: true,
|
|
1733
|
+
write: true,
|
|
1734
|
+
def: false,
|
|
1735
|
+
engine: `system.adapter.${instance}`,
|
|
1736
|
+
enabled: true,
|
|
1737
|
+
},
|
|
1738
|
+
native: {
|
|
1739
|
+
onTrue: { trigger: {}, cron: null, astro: null },
|
|
1740
|
+
onFalse: { enabled: false, trigger: {}, cron: null, astro: null },
|
|
1741
|
+
members: params.members.map(member => ({
|
|
1742
|
+
id: member.id,
|
|
1743
|
+
setIfTrue: member.value,
|
|
1744
|
+
setIfFalse: null,
|
|
1745
|
+
stopAllDelays: true,
|
|
1746
|
+
delay: 0,
|
|
1747
|
+
disabled: false,
|
|
1748
|
+
})),
|
|
1749
|
+
burstInterval: 0,
|
|
1750
|
+
},
|
|
1751
|
+
};
|
|
1752
|
+
await this.adapter.setForeignObjectAsync(id, sceneObj, { user: this.defaultUser });
|
|
1753
|
+
return { id, members: params.members.length };
|
|
1754
|
+
}
|
|
1755
|
+
// --- helpers ---
|
|
1756
|
+
/** Resolve the member ids of a room/function enum matched by id or (localized) name. */
|
|
1757
|
+
async getEnumMembers(prefix, nameOrId) {
|
|
1758
|
+
const enums = await this.adapter.getForeignObjectsAsync(`${prefix}*`, 'enum', { user: this.defaultUser });
|
|
1759
|
+
const needle = nameOrId.toLowerCase();
|
|
1760
|
+
for (const [id, obj] of Object.entries(enums || {})) {
|
|
1761
|
+
if (id.toLowerCase() === needle || this.getName(obj?.common?.name).toLowerCase() === needle) {
|
|
1762
|
+
return obj?.common?.members || [];
|
|
1763
|
+
}
|
|
1764
|
+
}
|
|
1765
|
+
return [];
|
|
1766
|
+
}
|
|
1767
|
+
/** Normalize an ioBroker name (string or {en, de, ...}) to a plain string. */
|
|
1768
|
+
getName(name) {
|
|
1769
|
+
if (!name) {
|
|
1770
|
+
return '';
|
|
1771
|
+
}
|
|
1772
|
+
if (typeof name === 'string') {
|
|
1773
|
+
return name;
|
|
1774
|
+
}
|
|
1775
|
+
return name.en || Object.values(name)[0] || '';
|
|
1776
|
+
}
|
|
1777
|
+
/** Parse an interval like "15m", "1h", "30s" into milliseconds. */
|
|
1778
|
+
parseInterval(interval) {
|
|
1779
|
+
const m = interval.match(/^(\d+)\s*(s|m|h|d)$/i);
|
|
1780
|
+
if (!m) {
|
|
1781
|
+
return undefined;
|
|
1782
|
+
}
|
|
1783
|
+
const value = parseInt(m[1], 10);
|
|
1784
|
+
const unit = { s: 1000, m: 60_000, h: 3_600_000, d: 86_400_000 }[m[2].toLowerCase()];
|
|
1785
|
+
return value * unit;
|
|
1786
|
+
}
|
|
1787
|
+
unload() {
|
|
1788
|
+
try {
|
|
1789
|
+
this.adapter.removeListener('log', this.onLog);
|
|
1790
|
+
}
|
|
1791
|
+
catch {
|
|
1792
|
+
// ignore
|
|
1793
|
+
}
|
|
1794
|
+
for (const session of Object.values(this.sessions)) {
|
|
1795
|
+
try {
|
|
1796
|
+
void session.transport.close();
|
|
1797
|
+
}
|
|
1798
|
+
catch {
|
|
1799
|
+
// ignore
|
|
1800
|
+
}
|
|
1801
|
+
}
|
|
1802
|
+
this.adapter.log.info('MCP server unloading');
|
|
1803
|
+
}
|
|
1804
|
+
}
|
|
1805
|
+
exports.default = McpServer;
|
|
1806
|
+
exports.McpServer = McpServer;
|
|
1807
|
+
//# sourceMappingURL=index.js.map
|