@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/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
- // The MCP endpoint. In standalone mode this is `/mcp`; as a web extension we own the
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
- this.adapter.log.debug(`${req.method} ${req.url} from ${req.ip}`);
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: { ids: zod_1.z.array(zod_1.z.string()).describe('Array of state IDs (wildcards "*" allowed)') },
758
- }, async ({ ids }) => {
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
- return ok({ ok: true, data: { states: await this.getStates(ids) } });
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
- return ok({ ok: true, data: { logs: await this.getLogs(args) } });
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 (args) => {
1047
+ }, async ({ limit = 50, offset = 0, ...filters }) => {
903
1048
  try {
904
- return ok({ ok: true, data: { results: await this.searchObjects(args) } });
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
- return ok({ ok: true, data: await this.historyQuery(args) });
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
- return ok({ ok: true, data: { path, files: await this.listFiles(path) } });
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
- return ok({ ok: true, data: { scripts: await this.listScripts(folder) } });
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. Waits until the installation is ' +
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, limit = 50 } = params;
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" or "file_name" + "file_content"');
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
- const adapterName = extractAdapterName(params.file_path || params.file_name || params.source || '');
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();