@iobroker/mcp-server 1.4.0 → 1.5.1

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,25 @@ 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;
270
+ if (this.linksAvailable) {
271
+ this.removeStaleTransferDirs();
272
+ }
198
273
  // State writes are declared destructive unless turned off explicitly
199
274
  this.config.setStateDestructive = this.config.setStateDestructive !== false;
200
275
  // Authentication is enforced by us only when we run standalone (we own the Express app) and
@@ -255,10 +330,7 @@ class McpServer {
255
330
  // Embedded in-process mode: no HTTP transport, so there is nothing to route.
256
331
  return;
257
332
  }
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';
333
+ const mcpPath = this.mcpPath;
262
334
  // The MCP transport needs a parsed JSON body; apply the parser per-route so we never
263
335
  // touch the body handling of the web adapter or other extensions when running embedded.
264
336
  const jsonParser = express_1.default.json({ limit: '4mb' });
@@ -275,7 +347,9 @@ class McpServer {
275
347
  // As an extension these would collide with the web adapter's own routes.
276
348
  if (!this.extension) {
277
349
  app.use((req, _res, next) => {
278
- this.adapter.log.debug(`${req.method} ${req.url} from ${req.ip}`);
350
+ // The token of a transfer link is a credential, so it does not go into the log
351
+ const url = req.url.replace(/\/transfer\/[^/?]+/, '/transfer/***');
352
+ this.adapter.log.debug(`${req.method} ${url} from ${req.ip}`);
279
353
  next();
280
354
  });
281
355
  app.get('/', (_req, res) => {
@@ -370,6 +444,18 @@ class McpServer {
370
444
  app.delete(mcpPath, ...guards, (req, res) => {
371
445
  void this.handleMcpSessionRequest(req, res);
372
446
  });
447
+ // --- Download and upload links (see createTransfer) ---
448
+ // No guards: the random token in the path is the credential. An authenticated MCP session got
449
+ // it from a tool and hands it to a program without MCP access, e.g. curl in a shell.
450
+ if (this.linksAvailable) {
451
+ app.get(`${mcpPath}/transfer/:token`, (req, res) => this.handleDownload(req, res));
452
+ app.put(`${mcpPath}/transfer/:token`, (req, res) => {
453
+ void this.handleUpload(req, res);
454
+ });
455
+ app.post(`${mcpPath}/transfer/:token`, (req, res) => {
456
+ void this.handleUpload(req, res);
457
+ });
458
+ }
373
459
  // Catch-all 404 + error handlers only in standalone mode; the web adapter provides its own.
374
460
  if (!this.extension) {
375
461
  app.use((req, res) => {
@@ -540,7 +626,7 @@ class McpServer {
540
626
  return;
541
627
  }
542
628
  const subscriptions = new Set();
543
- const server = this.createServer(subscriptions);
629
+ const server = this.createServer(subscriptions, this.getBaseUrl(req));
544
630
  transport = new streamableHttp_js_1.StreamableHTTPServerTransport({
545
631
  sessionIdGenerator: () => (0, node_crypto_1.randomUUID)(),
546
632
  onsessioninitialized: (sid) => {
@@ -722,15 +808,68 @@ class McpServer {
722
808
  * Create a new MCP SDK server with all ioBroker tools registered.
723
809
  *
724
810
  * @param subscriptions the per-session set of subscribed state ids (mutated by subscribe/unsubscribe)
811
+ * @param baseUrl public address of this server, for download/upload links (none in-process)
725
812
  */
726
- createServer(subscriptions) {
813
+ createServer(subscriptions, baseUrl) {
727
814
  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
815
  const fail = (error) => {
730
816
  const message = error instanceof Error ? error.message : String(error);
731
817
  this.adapter.log.warn(`MCP tool error: ${message}`);
732
818
  return { content: [{ type: 'text', text: JSON.stringify({ ok: false, error: message }) }], isError: true };
733
819
  };
820
+ const canLink = this.linksAvailable && !!baseUrl;
821
+ // A download link for data that is too large for a tool result
822
+ const download = (name, data, contentType) => ({
823
+ download_url: this.transferUrl(baseUrl, this.createTransfer('download', name, data, contentType)),
824
+ expires_in: TRANSFER_TTL / 1000,
825
+ });
826
+ const linkHint = 'fetch it with e.g. `curl -o <file> "<download_url>"` if you can run shell commands';
827
+ // Clients reject results above 1 MiB without telling the server, so never send one
828
+ const ok = (data) => {
829
+ const text = JSON.stringify(data);
830
+ if (wireSize(text) <= MAX_RESULT_BYTES) {
831
+ return { content: [{ type: 'text', text }] };
832
+ }
833
+ const size = `${Math.round(Buffer.byteLength(text) / 1024)} KB`;
834
+ if (canLink) {
835
+ return {
836
+ content: [
837
+ {
838
+ type: 'text',
839
+ text: JSON.stringify({
840
+ ok: true,
841
+ too_large: true,
842
+ size,
843
+ ...download('result.json', text, 'application/json'),
844
+ hint: `The result (${size}) is too large for one answer: narrow the request down, or ${linkHint}`,
845
+ }),
846
+ },
847
+ ],
848
+ };
849
+ }
850
+ return fail(new Error(`The result (${size}) is larger than ${MAX_RESULT_BYTES / 1024} KB, which MCP clients accept. ` +
851
+ 'Narrow the request down (filters, wildcards, time range) or use offset/limit.'));
852
+ };
853
+ // Describe a cut list: how to get the rest, and a link to all of it (if it was cut for its size)
854
+ const pageInfo = (page, all, name, link = true) => {
855
+ if (!page.truncated) {
856
+ return {};
857
+ }
858
+ const info = { truncated: true, total: page.total };
859
+ const ways = [];
860
+ if (page.nextOffset !== undefined) {
861
+ info.nextOffset = page.nextOffset;
862
+ ways.push('call again with offset=nextOffset');
863
+ }
864
+ ways.push('narrow the request down');
865
+ if (canLink && link) {
866
+ Object.assign(info, download(name, JSON.stringify(all), 'application/json'));
867
+ ways.push(`get all ${all.length} entries from download_url: ${linkHint}`);
868
+ }
869
+ info.hint = `${link ? 'Not everything fits into one answer' : 'There are more entries'}: ${ways.join(', or ')}`;
870
+ return info;
871
+ };
872
+ const offsetSchema = zod_1.z.number().int().min(0).optional().describe('Skip this many entries (for paging)');
734
873
  // Ask the user before a dangerous action, if the client can (MCP elicitation). Clients without
735
874
  // elicitation rely on the destructiveHint of the tool instead.
736
875
  const confirm = async (message) => {
@@ -754,10 +893,15 @@ class McpServer {
754
893
  annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
755
894
  description: 'Retrieve the current value of one or multiple states. IDs may contain wildcards, ' +
756
895
  '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 }) => {
896
+ inputSchema: {
897
+ ids: zod_1.z.array(zod_1.z.string()).describe('Array of state IDs (wildcards "*" allowed)'),
898
+ offset: offsetSchema,
899
+ },
900
+ }, async ({ ids, offset }) => {
759
901
  try {
760
- return ok({ ok: true, data: { states: await this.getStates(ids) } });
902
+ const states = await this.getStates(ids);
903
+ const page = fitItems(states, offset);
904
+ return ok({ ok: true, data: { states: page.items, ...pageInfo(page, states, 'states.json') } });
761
905
  }
762
906
  catch (e) {
763
907
  return fail(e);
@@ -839,7 +983,10 @@ class McpServer {
839
983
  },
840
984
  }, async (args) => {
841
985
  try {
842
- return ok({ ok: true, data: { logs: await this.getLogs(args) } });
986
+ // Keep the newest lines if not all of them fit
987
+ const logs = await this.getLogs(args);
988
+ const page = fitItems(logs, 0, true);
989
+ return ok({ ok: true, data: { logs: page.items, ...pageInfo(page, logs, 'logs.json') } });
843
990
  }
844
991
  catch (e) {
845
992
  return fail(e);
@@ -897,11 +1044,23 @@ class McpServer {
897
1044
  role: zod_1.z.string().optional(),
898
1045
  room: zod_1.z.string().optional(),
899
1046
  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)'),
1047
+ limit: zod_1.z.number().int().min(1).optional().describe('Maximum number of results (default 50)'),
1048
+ offset: offsetSchema,
901
1049
  },
902
- }, async (args) => {
1050
+ }, async ({ limit = 50, offset = 0, ...filters }) => {
903
1051
  try {
904
- return ok({ ok: true, data: { results: await this.searchObjects(args) } });
1052
+ const matches = await this.searchObjects(filters);
1053
+ const page = fitItems(matches.slice(0, offset + limit), offset);
1054
+ const tooLarge = page.truncated;
1055
+ if (!page.truncated && matches.length > offset + limit) {
1056
+ page.truncated = true;
1057
+ page.nextOffset = offset + limit;
1058
+ }
1059
+ page.total = matches.length;
1060
+ return ok({
1061
+ ok: true,
1062
+ data: { results: page.items, ...pageInfo(page, matches, 'objects.json', tooLarge) },
1063
+ });
905
1064
  }
906
1065
  catch (e) {
907
1066
  return fail(e);
@@ -964,10 +1123,16 @@ class McpServer {
964
1123
  .describe('Quantile (0-1), only used with agg=quantile'),
965
1124
  interval: zod_1.z.string().optional().describe('Aggregation interval, e.g. 15m, 1h'),
966
1125
  limit: zod_1.z.number().int().optional().describe('Maximum number of values (default 1000)'),
1126
+ offset: offsetSchema,
967
1127
  },
968
- }, async (args) => {
1128
+ }, async ({ offset, ...args }) => {
969
1129
  try {
970
- return ok({ ok: true, data: await this.historyQuery(args) });
1130
+ const result = await this.historyQuery(args);
1131
+ const page = fitItems(result.values, offset);
1132
+ return ok({
1133
+ ok: true,
1134
+ data: { ...result, values: page.items, ...pageInfo(page, result.values, 'history.json') },
1135
+ });
971
1136
  }
972
1137
  catch (e) {
973
1138
  return fail(e);
@@ -1108,7 +1273,8 @@ class McpServer {
1108
1273
  `At most \`length\` bytes (default ${DEFAULT_MAX_READ_BYTES}) are returned per call; ` +
1109
1274
  'large files are read in chunks: when the result has `truncated: true`, call again with ' +
1110
1275
  '`offset` set to the returned `nextOffset` until `truncated` is false. ' +
1111
- '`size` is the total file size in bytes.',
1276
+ '`size` is the total file size in bytes. If you can run shell commands (e.g. Claude Code), ' +
1277
+ 'set `download: true` instead to get a link to the whole file, which you fetch with curl.',
1112
1278
  inputSchema: {
1113
1279
  path: zod_1.z.string().describe('Path as "<adapter>/<dir>/<file>"'),
1114
1280
  base64: zod_1.z.boolean().optional().describe('Return binary content base64-encoded'),
@@ -1124,9 +1290,34 @@ class McpServer {
1124
1290
  .min(1)
1125
1291
  .optional()
1126
1292
  .describe(`Maximum number of bytes to return (default ${DEFAULT_MAX_READ_BYTES})`),
1293
+ download: zod_1.z
1294
+ .boolean()
1295
+ .optional()
1296
+ .describe('Return a download link to the whole file instead of its content'),
1127
1297
  },
1128
- }, async ({ path, base64, offset, length }) => {
1298
+ }, async ({ path, base64, offset, length, download: asLink }) => {
1129
1299
  try {
1300
+ if (asLink) {
1301
+ if (!canLink) {
1302
+ throw new Error(this.linksUnavailableReason || 'Links are not available');
1303
+ }
1304
+ const { adapterName, fileName } = McpServer.parseFilePath(path);
1305
+ const file = await this.adapter.readFileAsync(adapterName, fileName, {
1306
+ user: this.defaultUser,
1307
+ });
1308
+ const data = Buffer.isBuffer(file.file) ? file.file : Buffer.from(file.file || '');
1309
+ const mimeType = file.mimeType || 'application/octet-stream';
1310
+ return ok({
1311
+ ok: true,
1312
+ data: {
1313
+ path,
1314
+ size: data.length,
1315
+ mimeType,
1316
+ ...download(fileName.split('/').pop() || 'file', data, mimeType),
1317
+ hint: linkHint,
1318
+ },
1319
+ });
1320
+ }
1130
1321
  return ok({ ok: true, data: await this.readFile(path, base64, offset, length) });
1131
1322
  }
1132
1323
  catch (e) {
@@ -1137,10 +1328,12 @@ class McpServer {
1137
1328
  title: 'List a directory',
1138
1329
  annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
1139
1330
  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 }) => {
1331
+ inputSchema: { path: zod_1.z.string().describe('Path as "<adapter>[/<dir>]"'), offset: offsetSchema },
1332
+ }, async ({ path, offset }) => {
1142
1333
  try {
1143
- return ok({ ok: true, data: { path, files: await this.listFiles(path) } });
1334
+ const files = await this.listFiles(path);
1335
+ const page = fitItems(files, offset);
1336
+ return ok({ ok: true, data: { path, files: page.items, ...pageInfo(page, files, 'files.json') } });
1144
1337
  }
1145
1338
  catch (e) {
1146
1339
  return fail(e);
@@ -1349,14 +1542,29 @@ class McpServer {
1349
1542
  idempotentHint: true,
1350
1543
  openWorldHint: false,
1351
1544
  },
1352
- description: 'Write a file to an adapter file storage, e.g. "vis-2.0/main/vis-views.json"',
1545
+ description: 'Write a file to an adapter file storage, e.g. "vis-2.0/main/vis-views.json". Give the ' +
1546
+ 'content, or - for a large file, if you can run shell commands - upload it with ' +
1547
+ 'prepare_upload and give its upload_id.',
1353
1548
  inputSchema: {
1354
1549
  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)'),
1550
+ content: zod_1.z.string().optional().describe('File content (UTF-8, or base64 when base64=true)'),
1356
1551
  base64: zod_1.z.boolean().optional().describe('Treat content as base64-encoded binary'),
1552
+ upload_id: zod_1.z.string().optional().describe('Id of a file uploaded via prepare_upload'),
1357
1553
  },
1358
- }, async ({ path, content, base64 }) => {
1554
+ }, async ({ path, content, base64, upload_id }) => {
1359
1555
  try {
1556
+ if ((content === undefined) === (upload_id === undefined)) {
1557
+ throw new Error('Give either "content" or "upload_id"');
1558
+ }
1559
+ if (upload_id !== undefined) {
1560
+ const upload = this.getUpload(upload_id);
1561
+ const { adapterName, fileName } = McpServer.parseFilePath(path);
1562
+ await this.adapter.writeFileAsync(adapterName, fileName, node_fs_1.default.readFileSync(upload.file), {
1563
+ user: this.defaultUser,
1564
+ });
1565
+ this.removeTransfer(upload_id);
1566
+ return ok({ ok: true, data: { path, size: upload.size } });
1567
+ }
1360
1568
  await this.writeFile(path, content, base64);
1361
1569
  return ok({ ok: true, data: { path } });
1362
1570
  }
@@ -1470,10 +1678,16 @@ class McpServer {
1470
1678
  'id, name, type, whether they run, and the javascript instance - without the source.',
1471
1679
  inputSchema: {
1472
1680
  folder: zod_1.z.string().optional().describe('Only scripts in this folder, e.g. "common" or "lights"'),
1681
+ offset: offsetSchema,
1473
1682
  },
1474
- }, async ({ folder }) => {
1683
+ }, async ({ folder, offset }) => {
1475
1684
  try {
1476
- return ok({ ok: true, data: { scripts: await this.listScripts(folder) } });
1685
+ const scripts = await this.listScripts(folder);
1686
+ const page = fitItems(scripts, offset);
1687
+ return ok({
1688
+ ok: true,
1689
+ data: { scripts: page.items, ...pageInfo(page, scripts, 'scripts.json') },
1690
+ });
1477
1691
  }
1478
1692
  catch (e) {
1479
1693
  return fail(e);
@@ -1685,6 +1899,44 @@ class McpServer {
1685
1899
  }
1686
1900
  });
1687
1901
  }
1902
+ // Large files cannot pass through the model: the client uploads them with a separate program
1903
+ if (canLink && (this.config.allowObjectChange || this.config.allowPackageInstall)) {
1904
+ server.registerTool('prepare_upload', {
1905
+ title: 'Prepare a file upload',
1906
+ annotations: {
1907
+ readOnlyHint: false,
1908
+ destructiveHint: false,
1909
+ idempotentHint: false,
1910
+ openWorldHint: false,
1911
+ },
1912
+ description: 'Get a one-time link to upload a file that is too large for a tool argument, e.g. a .tgz ' +
1913
+ 'for install_package or a big file for write_file. Only useful if you can run shell ' +
1914
+ 'commands (e.g. Claude Code): upload the local file with `curl -T <file> "<url>"`, then ' +
1915
+ `pass the returned upload_id to install_package or write_file. Valid for ${TRANSFER_TTL / 60_000} minutes, ` +
1916
+ `up to ${MAX_UPLOAD_BYTES / 1024 / 1024} MB.`,
1917
+ inputSchema: {
1918
+ name: zod_1.z.string().describe('File name, e.g. "iobroker.hue-3.9.1.tgz"'),
1919
+ },
1920
+ }, ({ name }) => {
1921
+ try {
1922
+ const token = this.createTransfer('upload', name);
1923
+ const url = this.transferUrl(baseUrl, token);
1924
+ return Promise.resolve(ok({
1925
+ ok: true,
1926
+ data: {
1927
+ upload_id: token,
1928
+ url,
1929
+ method: 'PUT',
1930
+ expires_in: TRANSFER_TTL / 1000,
1931
+ command: `curl -T "${name}" "${url}"`,
1932
+ },
1933
+ }));
1934
+ }
1935
+ catch (e) {
1936
+ return Promise.resolve(fail(e));
1937
+ }
1938
+ });
1939
+ }
1688
1940
  // Installing packages runs arbitrary code on the host, so it is a separate opt-in for developers.
1689
1941
  if (this.config.allowPackageInstall) {
1690
1942
  server.registerTool('install_package', {
@@ -1700,7 +1952,8 @@ class McpServer {
1700
1952
  'package ("iobroker.hue@3.9.0" or just "hue"), a GitHub repository ' +
1701
1953
  '("ioBroker/ioBroker.hue#main" or https URL), an https URL of a .tgz or a .tgz path on the ' +
1702
1954
  '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 ' +
1955
+ '`file_name` + `file_content` - a small .tgz as base64; or `upload_id` - a .tgz uploaded ' +
1956
+ 'via prepare_upload (for large files, if you can run shell commands). Waits until the installation is ' +
1704
1957
  'finished (or `timeout`), returns the exit code and the last output lines. Enabled instances ' +
1705
1958
  'of the adapter are restarted afterwards; no new instance is created.',
1706
1959
  inputSchema: {
@@ -1717,6 +1970,7 @@ class McpServer {
1717
1970
  .optional()
1718
1971
  .describe('File name of the .tgz in file_content, e.g. "iobroker.hue-3.9.0.tgz"'),
1719
1972
  file_content: zod_1.z.string().optional().describe('The .tgz file as base64'),
1973
+ upload_id: zod_1.z.string().optional().describe('Id of a .tgz uploaded via prepare_upload'),
1720
1974
  host: zod_1.z.string().optional().describe('Target host (default: the host of this instance)'),
1721
1975
  debug: zod_1.z.boolean().optional().describe('Install with --debug for more output'),
1722
1976
  restart_instances: zod_1.z
@@ -1733,7 +1987,7 @@ class McpServer {
1733
1987
  },
1734
1988
  }, async (args) => {
1735
1989
  try {
1736
- await confirm(`Install the package "${args.source || args.file_path || args.file_name}"? It runs with full rights on the host.`);
1990
+ 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
1991
  return ok({ ok: true, data: await this.installPackage(args) });
1738
1992
  }
1739
1993
  catch (e) {
@@ -2429,7 +2683,7 @@ class McpServer {
2429
2683
  };
2430
2684
  }
2431
2685
  async searchObjects(params) {
2432
- const { query = '', type, role, room, adapter, limit = 50 } = params;
2686
+ const { query = '', type, role, room, adapter } = params;
2433
2687
  const result = await this.adapter.getObjectListAsync({
2434
2688
  startkey: '',
2435
2689
  endkey: '\u9999',
@@ -2464,9 +2718,6 @@ class McpServer {
2464
2718
  name,
2465
2719
  adapter: sourceAdapter,
2466
2720
  });
2467
- if (results.length >= limit) {
2468
- break;
2469
- }
2470
2721
  }
2471
2722
  return results;
2472
2723
  }
@@ -2914,11 +3165,18 @@ class McpServer {
2914
3165
  * ioBroker.admin installs from a custom URL or a .tgz file, and wait for the result.
2915
3166
  */
2916
3167
  async installPackage(params) {
2917
- const given = [params.source, params.file_path, params.file_content].filter(one => one !== undefined);
3168
+ const given = [params.source, params.file_path, params.file_content, params.upload_id].filter(one => one !== undefined);
2918
3169
  if (given.length !== 1) {
2919
- throw new Error('Give exactly one of "source", "file_path" or "file_name" + "file_content"');
3170
+ throw new Error('Give exactly one of "source", "file_path", "file_name" + "file_content" or "upload_id"');
2920
3171
  }
2921
3172
  const host = await this.resolveHost(params.host);
3173
+ let upload;
3174
+ if (params.upload_id !== undefined) {
3175
+ upload = this.getUpload(params.upload_id);
3176
+ if (!upload.name.toLowerCase().endsWith('.tgz')) {
3177
+ throw new Error('Only .tgz files can be installed');
3178
+ }
3179
+ }
2922
3180
  let source;
2923
3181
  let files;
2924
3182
  if (params.source !== undefined) {
@@ -2941,6 +3199,10 @@ class McpServer {
2941
3199
  data = Buffer.isBuffer(result.file) ? result.file : Buffer.from(result.file || '');
2942
3200
  fileName = storageName.split('/').pop() || '';
2943
3201
  }
3202
+ else if (upload) {
3203
+ data = node_fs_1.default.readFileSync(upload.file);
3204
+ fileName = upload.name;
3205
+ }
2944
3206
  else {
2945
3207
  if (!params.file_name) {
2946
3208
  throw new Error('"file_content" requires "file_name"');
@@ -2962,7 +3224,10 @@ class McpServer {
2962
3224
  files = [{ name: source, file: data.toString('base64') }];
2963
3225
  }
2964
3226
  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 || '');
3227
+ if (upload && result.finished) {
3228
+ this.removeTransfer(params.upload_id);
3229
+ }
3230
+ const adapterName = extractAdapterName(params.file_path || params.file_name || upload?.name || params.source || '');
2966
3231
  const restarted = result.success && adapterName && params.restart_instances !== false
2967
3232
  ? await this.restartAdapterInstances(adapterName)
2968
3233
  : [];
@@ -3408,6 +3673,168 @@ class McpServer {
3408
3673
  await this.adapter.setForeignObjectAsync(id, { type: 'state', common, native: {} }, { user: this.defaultUser });
3409
3674
  return { id, target: params.target, ...(params.write_target ? { write_target: params.write_target } : {}) };
3410
3675
  }
3676
+ /**
3677
+ * Create a download link for `data`, or an upload link when `data` is undefined, and return its
3678
+ * token. The file lives in a temporary folder until the link expires (or the upload is used).
3679
+ */
3680
+ createTransfer(kind, name, data, contentType = 'application/octet-stream') {
3681
+ this.cleanupTransfers();
3682
+ if (!this.transferDir) {
3683
+ this.transferDir = node_fs_1.default.mkdtempSync(node_path_1.default.join(node_os_1.default.tmpdir(), 'iobroker-mcp-'));
3684
+ this.transferTimer = setInterval(() => this.cleanupTransfers(), 60_000);
3685
+ this.transferTimer.unref?.();
3686
+ }
3687
+ const token = (0, node_crypto_1.randomBytes)(32).toString('hex');
3688
+ // One folder per link, so the file keeps its name (`npm` needs the .tgz extension)
3689
+ const folder = node_path_1.default.join(this.transferDir, token);
3690
+ // recursive: another MCP server may have removed our folder as stale (see removeStaleTransferDirs)
3691
+ node_fs_1.default.mkdirSync(folder, { recursive: true });
3692
+ const file = node_path_1.default.join(folder, name.replace(/[^a-zA-Z0-9._-]/g, '_') || 'file');
3693
+ if (data !== undefined) {
3694
+ node_fs_1.default.writeFileSync(file, data);
3695
+ }
3696
+ this.transfers.set(token, {
3697
+ kind,
3698
+ name,
3699
+ file,
3700
+ contentType,
3701
+ expires: Date.now() + TRANSFER_TTL,
3702
+ size: data !== undefined ? Buffer.byteLength(data) : undefined,
3703
+ });
3704
+ return token;
3705
+ }
3706
+ /**
3707
+ * Remove the transfer folders that a process left behind when it was killed. Other MCP servers on this
3708
+ * host (web extension, standalone, admin) use the same temp folder, so only folders unchanged for longer
3709
+ * than a link lives are removed: every new link creates a subfolder and so touches its folder.
3710
+ */
3711
+ removeStaleTransferDirs() {
3712
+ const tmp = node_os_1.default.tmpdir();
3713
+ try {
3714
+ for (const name of node_fs_1.default.readdirSync(tmp)) {
3715
+ if (!name.startsWith('iobroker-mcp-')) {
3716
+ continue;
3717
+ }
3718
+ const dir = node_path_1.default.join(tmp, name);
3719
+ try {
3720
+ const stat = node_fs_1.default.statSync(dir);
3721
+ if (stat.isDirectory() && Date.now() - stat.mtimeMs > TRANSFER_TTL) {
3722
+ node_fs_1.default.rmSync(dir, { recursive: true, force: true });
3723
+ this.adapter.log.debug(`Removed stale transfer folder "${dir}"`);
3724
+ }
3725
+ }
3726
+ catch (e) {
3727
+ this.adapter.log.debug(`Cannot remove stale transfer folder "${dir}": ${e}`);
3728
+ }
3729
+ }
3730
+ }
3731
+ catch (e) {
3732
+ this.adapter.log.debug(`Cannot read "${tmp}": ${e}`);
3733
+ }
3734
+ }
3735
+ /** The public URL of a download or upload link. */
3736
+ transferUrl(baseUrl, token) {
3737
+ return `${baseUrl}${this.mcpPath}/transfer/${token}`;
3738
+ }
3739
+ /** Remove a link and its file. */
3740
+ removeTransfer(token) {
3741
+ const transfer = this.transfers.get(token);
3742
+ if (transfer) {
3743
+ this.transfers.delete(token);
3744
+ try {
3745
+ node_fs_1.default.rmSync(node_path_1.default.dirname(transfer.file), { recursive: true, force: true });
3746
+ }
3747
+ catch (e) {
3748
+ this.adapter.log.warn(`Cannot delete "${transfer.file}": ${e}`);
3749
+ }
3750
+ }
3751
+ }
3752
+ /** Remove the expired links. */
3753
+ cleanupTransfers() {
3754
+ const now = Date.now();
3755
+ for (const [token, transfer] of this.transfers) {
3756
+ if (transfer.expires < now) {
3757
+ this.removeTransfer(token);
3758
+ }
3759
+ }
3760
+ }
3761
+ /** `GET <mcp>/transfer/<token>`: send the file of a download link. */
3762
+ handleDownload(req, res) {
3763
+ const transfer = this.transfers.get(String(req.params.token));
3764
+ if (!transfer || transfer.kind !== 'download' || transfer.expires < Date.now()) {
3765
+ res.status(404).json({ error: 'Unknown or expired link' });
3766
+ return;
3767
+ }
3768
+ res.setHeader('Content-Type', transfer.contentType);
3769
+ res.setHeader('Content-Disposition', `attachment; filename="${node_path_1.default.basename(transfer.file)}"`);
3770
+ if (transfer.size !== undefined) {
3771
+ res.setHeader('Content-Length', transfer.size);
3772
+ }
3773
+ node_fs_1.default.createReadStream(transfer.file).pipe(res);
3774
+ }
3775
+ /** `PUT`/`POST <mcp>/transfer/<token>`: receive the file of an upload link (raw body, e.g. `curl -T`). */
3776
+ async handleUpload(req, res) {
3777
+ const token = String(req.params.token);
3778
+ const transfer = this.transfers.get(token);
3779
+ if (!transfer || transfer.kind !== 'upload' || transfer.expires < Date.now()) {
3780
+ res.status(404).json({ error: 'Unknown or expired link' });
3781
+ return;
3782
+ }
3783
+ if (transfer.size !== undefined) {
3784
+ res.status(409).json({ error: 'This link was already used' });
3785
+ return;
3786
+ }
3787
+ try {
3788
+ transfer.size = await McpServer.receiveBody(req, transfer.file);
3789
+ this.adapter.log.info(`Received upload "${transfer.name}" (${transfer.size} bytes)`);
3790
+ res.json({ ok: true, upload_id: token, name: transfer.name, size: transfer.size });
3791
+ }
3792
+ catch (e) {
3793
+ const error = e;
3794
+ node_fs_1.default.rmSync(transfer.file, { force: true });
3795
+ res.status(error.status || 400).json({ error: error.message });
3796
+ }
3797
+ }
3798
+ /** Write the raw body of a request to a file and return its size. */
3799
+ static receiveBody(req, file) {
3800
+ const tooLarge = () => Object.assign(new Error(`The file is larger than ${MAX_UPLOAD_BYTES / 1024 / 1024} MB`), { status: 413 });
3801
+ // The body parsers of the web adapter may have read the stream already and kept the bytes
3802
+ const read = req.rawBody ?? (Buffer.isBuffer(req.body) ? req.body : null);
3803
+ if (read) {
3804
+ if (read.length > MAX_UPLOAD_BYTES) {
3805
+ return Promise.reject(tooLarge());
3806
+ }
3807
+ node_fs_1.default.writeFileSync(file, read);
3808
+ return Promise.resolve(read.length);
3809
+ }
3810
+ return new Promise((resolve, reject) => {
3811
+ const out = node_fs_1.default.createWriteStream(file);
3812
+ let size = 0;
3813
+ req.on('data', (chunk) => {
3814
+ size += chunk.length;
3815
+ if (size > MAX_UPLOAD_BYTES) {
3816
+ req.unpipe(out);
3817
+ out.destroy();
3818
+ reject(tooLarge());
3819
+ }
3820
+ });
3821
+ req.on('error', reject);
3822
+ out.on('error', reject);
3823
+ out.on('finish', () => resolve(size));
3824
+ req.pipe(out);
3825
+ });
3826
+ }
3827
+ /** Get a received upload by its id, failing if it is unknown, expired or still empty. */
3828
+ getUpload(uploadId) {
3829
+ const transfer = this.transfers.get(uploadId);
3830
+ if (!transfer || transfer.kind !== 'upload' || transfer.expires < Date.now()) {
3831
+ throw new Error(`Unknown or expired upload "${uploadId}" - create a new one with prepare_upload`);
3832
+ }
3833
+ if (transfer.size === undefined) {
3834
+ throw new Error(`Nothing was uploaded to "${uploadId}" yet - upload the file to its url first`);
3835
+ }
3836
+ return transfer;
3837
+ }
3411
3838
  // --- helpers ---
3412
3839
  /** Resolve the member ids of a room/function enum matched by id or (localized) name. */
3413
3840
  async getEnumMembers(prefix, nameOrId) {
@@ -3448,6 +3875,15 @@ class McpServer {
3448
3875
  catch {
3449
3876
  // ignore
3450
3877
  }
3878
+ if (this.transferTimer) {
3879
+ clearInterval(this.transferTimer);
3880
+ this.transferTimer = undefined;
3881
+ }
3882
+ if (this.transferDir) {
3883
+ node_fs_1.default.rmSync(this.transferDir, { recursive: true, force: true });
3884
+ this.transferDir = undefined;
3885
+ this.transfers.clear();
3886
+ }
3451
3887
  for (const session of Object.values(this.sessions)) {
3452
3888
  try {
3453
3889
  void session.transport.close();