@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/README.md +32 -3
- package/build/index.d.ts +40 -0
- package/build/index.js +472 -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,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
|
-
|
|
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
|
-
|
|
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: {
|
|
758
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
1050
|
+
}, async ({ limit = 50, offset = 0, ...filters }) => {
|
|
903
1051
|
try {
|
|
904
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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"
|
|
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
|
-
|
|
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();
|