@iobroker/mcp-server 1.4.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 +29 -3
- package/build/index.d.ts +34 -0
- package/build/index.js +439 -36
- package/build/index.js.map +1 -1
- package/build/types.d.ts +7 -1
- package/build/types.js +1 -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"));
|
|
@@ -79,6 +80,43 @@ const DEFAULT_MAX_READ_BYTES = 512 * 1024;
|
|
|
79
80
|
const DEFAULT_INSTALL_TIMEOUT = 300;
|
|
80
81
|
/** How many output lines of an installation are returned to the client. */
|
|
81
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
|
+
}
|
|
82
120
|
/**
|
|
83
121
|
* Extract the adapter name from an install source, like `iobroker.admin-7.8.6.tgz`,
|
|
84
122
|
* `ioBroker/ioBroker.admin#main`, `iobroker.admin@7.8.6` or `admin`. Same rules as in ioBroker.admin.
|
|
@@ -159,6 +197,20 @@ class McpServer {
|
|
|
159
197
|
oauth2;
|
|
160
198
|
/** Installations started by `install_package`, keyed by the `cmdExec` session id. */
|
|
161
199
|
cmdSessions = new Map();
|
|
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;
|
|
162
214
|
constructor(server,
|
|
163
215
|
// The permission toggles are explicit overrides for embedded mode (see `McpPermissions`)
|
|
164
216
|
webSettings, adapter, instanceSettings, app) {
|
|
@@ -171,6 +223,10 @@ class McpServer {
|
|
|
171
223
|
};
|
|
172
224
|
this.extension = !!instanceSettings;
|
|
173
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';
|
|
174
230
|
// Determine the ioBroker user whose permissions all MCP requests run with.
|
|
175
231
|
// Prefer this adapter's own setting; when embedded, fall back to the host web server's
|
|
176
232
|
// default user; finally to "admin". Always normalize to the "system.user." prefix.
|
|
@@ -195,6 +251,22 @@ class McpServer {
|
|
|
195
251
|
this.config.sendToAllowlist = (this.config.sendToAllowlist || '').trim();
|
|
196
252
|
// Confirmations via elicitation are on unless turned off explicitly
|
|
197
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;
|
|
198
270
|
// State writes are declared destructive unless turned off explicitly
|
|
199
271
|
this.config.setStateDestructive = this.config.setStateDestructive !== false;
|
|
200
272
|
// Authentication is enforced by us only when we run standalone (we own the Express app) and
|
|
@@ -255,10 +327,7 @@ class McpServer {
|
|
|
255
327
|
// Embedded in-process mode: no HTTP transport, so there is nothing to route.
|
|
256
328
|
return;
|
|
257
329
|
}
|
|
258
|
-
|
|
259
|
-
// `/<adapter>/` namespace (e.g. `/mcp/`) within the shared web server. `.replace` strips
|
|
260
|
-
// the trailing slash so the route matches both `/mcp` and `/mcp/` (Express, non-strict).
|
|
261
|
-
const mcpPath = this.extension ? this.routerPrefix.replace(/\/$/, '') : '/mcp';
|
|
330
|
+
const mcpPath = this.mcpPath;
|
|
262
331
|
// The MCP transport needs a parsed JSON body; apply the parser per-route so we never
|
|
263
332
|
// touch the body handling of the web adapter or other extensions when running embedded.
|
|
264
333
|
const jsonParser = express_1.default.json({ limit: '4mb' });
|
|
@@ -275,7 +344,9 @@ class McpServer {
|
|
|
275
344
|
// As an extension these would collide with the web adapter's own routes.
|
|
276
345
|
if (!this.extension) {
|
|
277
346
|
app.use((req, _res, next) => {
|
|
278
|
-
|
|
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}`);
|
|
279
350
|
next();
|
|
280
351
|
});
|
|
281
352
|
app.get('/', (_req, res) => {
|
|
@@ -370,6 +441,18 @@ class McpServer {
|
|
|
370
441
|
app.delete(mcpPath, ...guards, (req, res) => {
|
|
371
442
|
void this.handleMcpSessionRequest(req, res);
|
|
372
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
|
+
}
|
|
373
456
|
// Catch-all 404 + error handlers only in standalone mode; the web adapter provides its own.
|
|
374
457
|
if (!this.extension) {
|
|
375
458
|
app.use((req, res) => {
|
|
@@ -540,7 +623,7 @@ class McpServer {
|
|
|
540
623
|
return;
|
|
541
624
|
}
|
|
542
625
|
const subscriptions = new Set();
|
|
543
|
-
const server = this.createServer(subscriptions);
|
|
626
|
+
const server = this.createServer(subscriptions, this.getBaseUrl(req));
|
|
544
627
|
transport = new streamableHttp_js_1.StreamableHTTPServerTransport({
|
|
545
628
|
sessionIdGenerator: () => (0, node_crypto_1.randomUUID)(),
|
|
546
629
|
onsessioninitialized: (sid) => {
|
|
@@ -722,15 +805,68 @@ class McpServer {
|
|
|
722
805
|
* Create a new MCP SDK server with all ioBroker tools registered.
|
|
723
806
|
*
|
|
724
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)
|
|
725
809
|
*/
|
|
726
|
-
createServer(subscriptions) {
|
|
810
|
+
createServer(subscriptions, baseUrl) {
|
|
727
811
|
const server = new mcp_js_1.McpServer({ name: SERVER_NAME, version: SERVER_VERSION }, { capabilities: { tools: {}, resources: { subscribe: true } } });
|
|
728
|
-
const ok = (data) => ({ content: [{ type: 'text', text: JSON.stringify(data) }] });
|
|
729
812
|
const fail = (error) => {
|
|
730
813
|
const message = error instanceof Error ? error.message : String(error);
|
|
731
814
|
this.adapter.log.warn(`MCP tool error: ${message}`);
|
|
732
815
|
return { content: [{ type: 'text', text: JSON.stringify({ ok: false, error: message }) }], isError: true };
|
|
733
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)');
|
|
734
870
|
// Ask the user before a dangerous action, if the client can (MCP elicitation). Clients without
|
|
735
871
|
// elicitation rely on the destructiveHint of the tool instead.
|
|
736
872
|
const confirm = async (message) => {
|
|
@@ -754,10 +890,15 @@ class McpServer {
|
|
|
754
890
|
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
755
891
|
description: 'Retrieve the current value of one or multiple states. IDs may contain wildcards, ' +
|
|
756
892
|
'e.g. "hue.0.*.brightness" expands to all matching states.',
|
|
757
|
-
inputSchema: {
|
|
758
|
-
|
|
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 }) => {
|
|
759
898
|
try {
|
|
760
|
-
|
|
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') } });
|
|
761
902
|
}
|
|
762
903
|
catch (e) {
|
|
763
904
|
return fail(e);
|
|
@@ -839,7 +980,10 @@ class McpServer {
|
|
|
839
980
|
},
|
|
840
981
|
}, async (args) => {
|
|
841
982
|
try {
|
|
842
|
-
|
|
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') } });
|
|
843
987
|
}
|
|
844
988
|
catch (e) {
|
|
845
989
|
return fail(e);
|
|
@@ -897,11 +1041,23 @@ class McpServer {
|
|
|
897
1041
|
role: zod_1.z.string().optional(),
|
|
898
1042
|
room: zod_1.z.string().optional(),
|
|
899
1043
|
adapter: zod_1.z.string().optional().describe('Filter by source adapter instance, e.g. "hue.0"'),
|
|
900
|
-
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,
|
|
901
1046
|
},
|
|
902
|
-
}, async (
|
|
1047
|
+
}, async ({ limit = 50, offset = 0, ...filters }) => {
|
|
903
1048
|
try {
|
|
904
|
-
|
|
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
|
+
});
|
|
905
1061
|
}
|
|
906
1062
|
catch (e) {
|
|
907
1063
|
return fail(e);
|
|
@@ -964,10 +1120,16 @@ class McpServer {
|
|
|
964
1120
|
.describe('Quantile (0-1), only used with agg=quantile'),
|
|
965
1121
|
interval: zod_1.z.string().optional().describe('Aggregation interval, e.g. 15m, 1h'),
|
|
966
1122
|
limit: zod_1.z.number().int().optional().describe('Maximum number of values (default 1000)'),
|
|
1123
|
+
offset: offsetSchema,
|
|
967
1124
|
},
|
|
968
|
-
}, async (args) => {
|
|
1125
|
+
}, async ({ offset, ...args }) => {
|
|
969
1126
|
try {
|
|
970
|
-
|
|
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
|
+
});
|
|
971
1133
|
}
|
|
972
1134
|
catch (e) {
|
|
973
1135
|
return fail(e);
|
|
@@ -1108,7 +1270,8 @@ class McpServer {
|
|
|
1108
1270
|
`At most \`length\` bytes (default ${DEFAULT_MAX_READ_BYTES}) are returned per call; ` +
|
|
1109
1271
|
'large files are read in chunks: when the result has `truncated: true`, call again with ' +
|
|
1110
1272
|
'`offset` set to the returned `nextOffset` until `truncated` is false. ' +
|
|
1111
|
-
'`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.',
|
|
1112
1275
|
inputSchema: {
|
|
1113
1276
|
path: zod_1.z.string().describe('Path as "<adapter>/<dir>/<file>"'),
|
|
1114
1277
|
base64: zod_1.z.boolean().optional().describe('Return binary content base64-encoded'),
|
|
@@ -1124,9 +1287,34 @@ class McpServer {
|
|
|
1124
1287
|
.min(1)
|
|
1125
1288
|
.optional()
|
|
1126
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'),
|
|
1127
1294
|
},
|
|
1128
|
-
}, async ({ path, base64, offset, length }) => {
|
|
1295
|
+
}, async ({ path, base64, offset, length, download: asLink }) => {
|
|
1129
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
|
+
}
|
|
1130
1318
|
return ok({ ok: true, data: await this.readFile(path, base64, offset, length) });
|
|
1131
1319
|
}
|
|
1132
1320
|
catch (e) {
|
|
@@ -1137,10 +1325,12 @@ class McpServer {
|
|
|
1137
1325
|
title: 'List a directory',
|
|
1138
1326
|
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
1139
1327
|
description: 'List a directory in an adapter file storage, e.g. "vis-2.0/main" or "0_userdata.0"',
|
|
1140
|
-
inputSchema: { path: zod_1.z.string().describe('Path as "<adapter>[/<dir>]"') },
|
|
1141
|
-
}, async ({ path }) => {
|
|
1328
|
+
inputSchema: { path: zod_1.z.string().describe('Path as "<adapter>[/<dir>]"'), offset: offsetSchema },
|
|
1329
|
+
}, async ({ path, offset }) => {
|
|
1142
1330
|
try {
|
|
1143
|
-
|
|
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') } });
|
|
1144
1334
|
}
|
|
1145
1335
|
catch (e) {
|
|
1146
1336
|
return fail(e);
|
|
@@ -1349,14 +1539,29 @@ class McpServer {
|
|
|
1349
1539
|
idempotentHint: true,
|
|
1350
1540
|
openWorldHint: false,
|
|
1351
1541
|
},
|
|
1352
|
-
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.',
|
|
1353
1545
|
inputSchema: {
|
|
1354
1546
|
path: zod_1.z.string().describe('Path as "<adapter>/<dir>/<file>"'),
|
|
1355
|
-
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)'),
|
|
1356
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'),
|
|
1357
1550
|
},
|
|
1358
|
-
}, async ({ path, content, base64 }) => {
|
|
1551
|
+
}, async ({ path, content, base64, upload_id }) => {
|
|
1359
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
|
+
}
|
|
1360
1565
|
await this.writeFile(path, content, base64);
|
|
1361
1566
|
return ok({ ok: true, data: { path } });
|
|
1362
1567
|
}
|
|
@@ -1470,10 +1675,16 @@ class McpServer {
|
|
|
1470
1675
|
'id, name, type, whether they run, and the javascript instance - without the source.',
|
|
1471
1676
|
inputSchema: {
|
|
1472
1677
|
folder: zod_1.z.string().optional().describe('Only scripts in this folder, e.g. "common" or "lights"'),
|
|
1678
|
+
offset: offsetSchema,
|
|
1473
1679
|
},
|
|
1474
|
-
}, async ({ folder }) => {
|
|
1680
|
+
}, async ({ folder, offset }) => {
|
|
1475
1681
|
try {
|
|
1476
|
-
|
|
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
|
+
});
|
|
1477
1688
|
}
|
|
1478
1689
|
catch (e) {
|
|
1479
1690
|
return fail(e);
|
|
@@ -1685,6 +1896,44 @@ class McpServer {
|
|
|
1685
1896
|
}
|
|
1686
1897
|
});
|
|
1687
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
|
+
}
|
|
1688
1937
|
// Installing packages runs arbitrary code on the host, so it is a separate opt-in for developers.
|
|
1689
1938
|
if (this.config.allowPackageInstall) {
|
|
1690
1939
|
server.registerTool('install_package', {
|
|
@@ -1700,7 +1949,8 @@ class McpServer {
|
|
|
1700
1949
|
'package ("iobroker.hue@3.9.0" or just "hue"), a GitHub repository ' +
|
|
1701
1950
|
'("ioBroker/ioBroker.hue#main" or https URL), an https URL of a .tgz or a .tgz path on the ' +
|
|
1702
1951
|
'target host; `file_path` - a .tgz in the ioBroker file storage ("0_userdata.0/x.tgz"); or ' +
|
|
1703
|
-
'`file_name` + `file_content` - a small .tgz as base64
|
|
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 ' +
|
|
1704
1954
|
'finished (or `timeout`), returns the exit code and the last output lines. Enabled instances ' +
|
|
1705
1955
|
'of the adapter are restarted afterwards; no new instance is created.',
|
|
1706
1956
|
inputSchema: {
|
|
@@ -1717,6 +1967,7 @@ class McpServer {
|
|
|
1717
1967
|
.optional()
|
|
1718
1968
|
.describe('File name of the .tgz in file_content, e.g. "iobroker.hue-3.9.0.tgz"'),
|
|
1719
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'),
|
|
1720
1971
|
host: zod_1.z.string().optional().describe('Target host (default: the host of this instance)'),
|
|
1721
1972
|
debug: zod_1.z.boolean().optional().describe('Install with --debug for more output'),
|
|
1722
1973
|
restart_instances: zod_1.z
|
|
@@ -1733,7 +1984,7 @@ class McpServer {
|
|
|
1733
1984
|
},
|
|
1734
1985
|
}, async (args) => {
|
|
1735
1986
|
try {
|
|
1736
|
-
await confirm(`Install the package "${args.source || args.file_path || args.file_name}"? It runs with full rights on the host.`);
|
|
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.`);
|
|
1737
1988
|
return ok({ ok: true, data: await this.installPackage(args) });
|
|
1738
1989
|
}
|
|
1739
1990
|
catch (e) {
|
|
@@ -2429,7 +2680,7 @@ class McpServer {
|
|
|
2429
2680
|
};
|
|
2430
2681
|
}
|
|
2431
2682
|
async searchObjects(params) {
|
|
2432
|
-
const { query = '', type, role, room, adapter
|
|
2683
|
+
const { query = '', type, role, room, adapter } = params;
|
|
2433
2684
|
const result = await this.adapter.getObjectListAsync({
|
|
2434
2685
|
startkey: '',
|
|
2435
2686
|
endkey: '\u9999',
|
|
@@ -2464,9 +2715,6 @@ class McpServer {
|
|
|
2464
2715
|
name,
|
|
2465
2716
|
adapter: sourceAdapter,
|
|
2466
2717
|
});
|
|
2467
|
-
if (results.length >= limit) {
|
|
2468
|
-
break;
|
|
2469
|
-
}
|
|
2470
2718
|
}
|
|
2471
2719
|
return results;
|
|
2472
2720
|
}
|
|
@@ -2914,11 +3162,18 @@ class McpServer {
|
|
|
2914
3162
|
* ioBroker.admin installs from a custom URL or a .tgz file, and wait for the result.
|
|
2915
3163
|
*/
|
|
2916
3164
|
async installPackage(params) {
|
|
2917
|
-
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);
|
|
2918
3166
|
if (given.length !== 1) {
|
|
2919
|
-
throw new Error('Give exactly one of "source", "file_path"
|
|
3167
|
+
throw new Error('Give exactly one of "source", "file_path", "file_name" + "file_content" or "upload_id"');
|
|
2920
3168
|
}
|
|
2921
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
|
+
}
|
|
3176
|
+
}
|
|
2922
3177
|
let source;
|
|
2923
3178
|
let files;
|
|
2924
3179
|
if (params.source !== undefined) {
|
|
@@ -2941,6 +3196,10 @@ class McpServer {
|
|
|
2941
3196
|
data = Buffer.isBuffer(result.file) ? result.file : Buffer.from(result.file || '');
|
|
2942
3197
|
fileName = storageName.split('/').pop() || '';
|
|
2943
3198
|
}
|
|
3199
|
+
else if (upload) {
|
|
3200
|
+
data = node_fs_1.default.readFileSync(upload.file);
|
|
3201
|
+
fileName = upload.name;
|
|
3202
|
+
}
|
|
2944
3203
|
else {
|
|
2945
3204
|
if (!params.file_name) {
|
|
2946
3205
|
throw new Error('"file_content" requires "file_name"');
|
|
@@ -2962,7 +3221,10 @@ class McpServer {
|
|
|
2962
3221
|
files = [{ name: source, file: data.toString('base64') }];
|
|
2963
3222
|
}
|
|
2964
3223
|
const result = await this.runHostCommand(host, `url ${source} --host ${host}${params.debug ? ' --debug' : ''}`, params.timeout, files);
|
|
2965
|
-
|
|
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 || '');
|
|
2966
3228
|
const restarted = result.success && adapterName && params.restart_instances !== false
|
|
2967
3229
|
? await this.restartAdapterInstances(adapterName)
|
|
2968
3230
|
: [];
|
|
@@ -3408,6 +3670,138 @@ class McpServer {
|
|
|
3408
3670
|
await this.adapter.setForeignObjectAsync(id, { type: 'state', common, native: {} }, { user: this.defaultUser });
|
|
3409
3671
|
return { id, target: params.target, ...(params.write_target ? { write_target: params.write_target } : {}) };
|
|
3410
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
|
+
}
|
|
3411
3805
|
// --- helpers ---
|
|
3412
3806
|
/** Resolve the member ids of a room/function enum matched by id or (localized) name. */
|
|
3413
3807
|
async getEnumMembers(prefix, nameOrId) {
|
|
@@ -3448,6 +3842,15 @@ class McpServer {
|
|
|
3448
3842
|
catch {
|
|
3449
3843
|
// ignore
|
|
3450
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
|
+
}
|
|
3451
3854
|
for (const session of Object.values(this.sessions)) {
|
|
3452
3855
|
try {
|
|
3453
3856
|
void session.transport.close();
|