ftp-mcp 1.1.0 → 1.2.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/index.js +1153 -555
- package/package.json +4 -1
- package/policy-engine.js +71 -0
- package/snapshot-manager.js +156 -0
- package/sync-manifest.js +75 -0
package/index.js
CHANGED
|
@@ -17,62 +17,67 @@ import * as diff from "diff";
|
|
|
17
17
|
import { z } from "zod";
|
|
18
18
|
import "dotenv/config";
|
|
19
19
|
import { Writable, Readable } from "stream";
|
|
20
|
+
import { SnapshotManager } from "./snapshot-manager.js";
|
|
21
|
+
import { PolicyEngine } from "./policy-engine.js";
|
|
22
|
+
import { SyncManifestManager } from "./sync-manifest.js";
|
|
23
|
+
import crypto from "crypto";
|
|
24
|
+
const DEFAULT_CONFIG_NAME = ".ftpconfig";
|
|
25
|
+
const CONFIG_FILE = process.env.FTP_CONFIG_PATH || path.join(process.cwd(), DEFAULT_CONFIG_NAME);
|
|
20
26
|
|
|
21
27
|
// --init: scaffold .ftpconfig.example into the user's current working directory
|
|
22
28
|
if (process.argv.includes("--init")) {
|
|
23
29
|
try {
|
|
24
30
|
const { intro, outro, text, password: promptPassword, select, confirm, note } = await import("@clack/prompts");
|
|
25
|
-
|
|
26
|
-
intro('🚀 Welcome to
|
|
27
|
-
|
|
31
|
+
|
|
32
|
+
intro('🚀 Welcome to FTP-MCP Initialization Wizard');
|
|
33
|
+
|
|
28
34
|
const host = await text({
|
|
29
35
|
message: 'Enter your FTP/SFTP Host (e.g. sftp://ftp.example.com)',
|
|
30
36
|
placeholder: 'sftp://127.0.0.1',
|
|
31
37
|
validate: (val) => val.length === 0 ? "Host is required!" : undefined,
|
|
32
38
|
});
|
|
33
|
-
|
|
39
|
+
|
|
34
40
|
const user = await text({
|
|
35
41
|
message: 'Enter your Username',
|
|
36
42
|
validate: (val) => val.length === 0 ? "User is required!" : undefined,
|
|
37
43
|
});
|
|
38
|
-
|
|
44
|
+
|
|
39
45
|
const pass = await promptPassword({
|
|
40
46
|
message: 'Enter your Password (optional if using keys)',
|
|
41
47
|
});
|
|
42
|
-
|
|
48
|
+
|
|
43
49
|
const port = await text({
|
|
44
50
|
message: 'Enter port (optional, defaults to 21 for FTP, 22 for SFTP)',
|
|
45
51
|
placeholder: '22'
|
|
46
52
|
});
|
|
47
|
-
|
|
53
|
+
|
|
48
54
|
const isSFTP = host.startsWith('sftp://');
|
|
49
55
|
let privateKey = '';
|
|
50
|
-
|
|
56
|
+
|
|
51
57
|
if (isSFTP) {
|
|
52
|
-
const usesKey = await confirm({ message: 'Are you using an SSH Private Key instead of a password?'});
|
|
58
|
+
const usesKey = await confirm({ message: 'Are you using an SSH Private Key instead of a password?' });
|
|
53
59
|
if (usesKey) {
|
|
54
60
|
privateKey = await text({
|
|
55
61
|
message: 'Path to your private key (e.g. ~/.ssh/id_rsa)',
|
|
56
62
|
});
|
|
57
63
|
}
|
|
58
64
|
}
|
|
59
|
-
|
|
65
|
+
|
|
60
66
|
const config = {
|
|
61
67
|
default: {
|
|
62
68
|
host: host,
|
|
63
69
|
user: user,
|
|
64
70
|
}
|
|
65
71
|
};
|
|
66
|
-
|
|
72
|
+
|
|
67
73
|
if (pass) config.default.password = pass;
|
|
68
74
|
if (port) config.default.port = parseInt(port, 10);
|
|
69
75
|
if (privateKey) config.default.privateKey = privateKey;
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
+
|
|
77
|
+
await fs.writeFile(CONFIG_FILE, JSON.stringify(config, null, 2), 'utf8');
|
|
78
|
+
|
|
79
|
+
note(`✅ Successfully generated config file at:\n${CONFIG_FILE}`, 'Success');
|
|
80
|
+
|
|
76
81
|
outro("You're ready to deploy with MCP! Ask your AI to 'list remote files'");
|
|
77
82
|
} catch (err) {
|
|
78
83
|
console.error(`❌ Init failed: ${err.message}`);
|
|
@@ -112,7 +117,7 @@ const DEFAULT_IGNORE_PATTERNS = [
|
|
|
112
117
|
|
|
113
118
|
async function loadIgnorePatterns(localPath) {
|
|
114
119
|
const patterns = [...DEFAULT_IGNORE_PATTERNS];
|
|
115
|
-
|
|
120
|
+
|
|
116
121
|
try {
|
|
117
122
|
const ftpignorePath = path.join(localPath, '.ftpignore');
|
|
118
123
|
const ftpignoreContent = await fs.readFile(ftpignorePath, 'utf8');
|
|
@@ -124,7 +129,7 @@ async function loadIgnorePatterns(localPath) {
|
|
|
124
129
|
} catch (e) {
|
|
125
130
|
// .ftpignore doesn't exist, that's fine
|
|
126
131
|
}
|
|
127
|
-
|
|
132
|
+
|
|
128
133
|
try {
|
|
129
134
|
const gitignorePath = path.join(localPath, '.gitignore');
|
|
130
135
|
const gitignoreContent = await fs.readFile(gitignorePath, 'utf8');
|
|
@@ -136,20 +141,32 @@ async function loadIgnorePatterns(localPath) {
|
|
|
136
141
|
} catch (e) {
|
|
137
142
|
// .gitignore doesn't exist, that's fine
|
|
138
143
|
}
|
|
139
|
-
|
|
144
|
+
|
|
140
145
|
return patterns;
|
|
141
146
|
}
|
|
142
147
|
|
|
148
|
+
function isSecretFile(filePath) {
|
|
149
|
+
const name = path.basename(filePath).toLowerCase();
|
|
150
|
+
return name === '.env' ||
|
|
151
|
+
name.startsWith('.env.') ||
|
|
152
|
+
name.endsWith('.key') ||
|
|
153
|
+
name.endsWith('.pem') ||
|
|
154
|
+
name.endsWith('.p12') ||
|
|
155
|
+
name.includes('id_rsa') ||
|
|
156
|
+
name.includes('secret') ||
|
|
157
|
+
name.includes('token');
|
|
158
|
+
}
|
|
159
|
+
|
|
143
160
|
function shouldIgnore(filePath, ignorePatterns, basePath) {
|
|
144
161
|
const relativePath = path.relative(basePath, filePath).replace(/\\/g, '/');
|
|
145
|
-
|
|
162
|
+
|
|
146
163
|
if (!ignorePatterns._ig) {
|
|
147
164
|
Object.defineProperty(ignorePatterns, '_ig', {
|
|
148
165
|
value: ignore().add(ignorePatterns),
|
|
149
166
|
enumerable: false
|
|
150
167
|
});
|
|
151
168
|
}
|
|
152
|
-
|
|
169
|
+
|
|
153
170
|
return ignorePatterns._ig.ignores(relativePath);
|
|
154
171
|
}
|
|
155
172
|
|
|
@@ -162,7 +179,14 @@ const ProfileConfigSchema = z.object({
|
|
|
162
179
|
readOnly: z.boolean().optional(),
|
|
163
180
|
privateKey: z.string().optional(),
|
|
164
181
|
passphrase: z.string().optional(),
|
|
165
|
-
agent: z.string().optional()
|
|
182
|
+
agent: z.string().optional(),
|
|
183
|
+
policies: z.object({
|
|
184
|
+
allowedPaths: z.array(z.string()).optional(),
|
|
185
|
+
blockedGlobs: z.array(z.string()).optional(),
|
|
186
|
+
maxFileSize: z.number().optional(),
|
|
187
|
+
neverDelete: z.array(z.string()).optional(),
|
|
188
|
+
patchOnly: z.array(z.string()).optional()
|
|
189
|
+
}).optional()
|
|
166
190
|
}).passthrough();
|
|
167
191
|
|
|
168
192
|
async function loadFTPConfig(profileName = null, forceEnv = false) {
|
|
@@ -176,7 +200,7 @@ async function loadFTPConfig(profileName = null, forceEnv = false) {
|
|
|
176
200
|
}
|
|
177
201
|
|
|
178
202
|
try {
|
|
179
|
-
const configPath =
|
|
203
|
+
const configPath = CONFIG_FILE;
|
|
180
204
|
const configData = await fs.readFile(configPath, 'utf8');
|
|
181
205
|
const config = JSON.parse(configData);
|
|
182
206
|
|
|
@@ -244,7 +268,7 @@ async function connectSFTP(config) {
|
|
|
244
268
|
username: config.user,
|
|
245
269
|
password: config.password
|
|
246
270
|
};
|
|
247
|
-
|
|
271
|
+
|
|
248
272
|
if (config.privateKey) connSettings.privateKey = readFileSync(path.resolve(config.privateKey), 'utf8');
|
|
249
273
|
if (config.passphrase) connSettings.passphrase = config.passphrase;
|
|
250
274
|
if (config.agent) connSettings.agent = config.agent;
|
|
@@ -254,8 +278,20 @@ async function connectSFTP(config) {
|
|
|
254
278
|
}
|
|
255
279
|
|
|
256
280
|
const connectionPool = new Map();
|
|
281
|
+
const connectingPromises = new Map();
|
|
257
282
|
const dirCache = new Map();
|
|
258
283
|
const CACHE_TTL = 15000;
|
|
284
|
+
const snapshotManager = new SnapshotManager();
|
|
285
|
+
const syncManifestManager = new SyncManifestManager();
|
|
286
|
+
|
|
287
|
+
const telemetry = {
|
|
288
|
+
activeConnections: 0,
|
|
289
|
+
cacheHits: 0,
|
|
290
|
+
cacheMisses: 0,
|
|
291
|
+
reconnects: 0,
|
|
292
|
+
bytesTransferred: 0,
|
|
293
|
+
syncDurations: []
|
|
294
|
+
};
|
|
259
295
|
|
|
260
296
|
function getPoolKey(config) {
|
|
261
297
|
return `${config.host}:${getPort(config.host, config.port)}:${config.user}`;
|
|
@@ -263,7 +299,11 @@ function getPoolKey(config) {
|
|
|
263
299
|
|
|
264
300
|
function getCached(poolKey, type, path) {
|
|
265
301
|
const entry = dirCache.get(`${poolKey}:${type}:${path}`);
|
|
266
|
-
if (entry && Date.now() - entry.timestamp < CACHE_TTL)
|
|
302
|
+
if (entry && Date.now() - entry.timestamp < CACHE_TTL) {
|
|
303
|
+
telemetry.cacheHits++;
|
|
304
|
+
return entry.data;
|
|
305
|
+
}
|
|
306
|
+
telemetry.cacheMisses++;
|
|
267
307
|
return null;
|
|
268
308
|
}
|
|
269
309
|
|
|
@@ -281,30 +321,67 @@ function invalidatePoolCache(poolKey) {
|
|
|
281
321
|
|
|
282
322
|
async function getClient(config) {
|
|
283
323
|
const poolKey = getPoolKey(config);
|
|
324
|
+
|
|
325
|
+
// 1. If we are already connecting to this host, wait for it
|
|
326
|
+
if (connectingPromises.has(poolKey)) {
|
|
327
|
+
return connectingPromises.get(poolKey);
|
|
328
|
+
}
|
|
329
|
+
|
|
284
330
|
let existing = connectionPool.get(poolKey);
|
|
285
|
-
|
|
331
|
+
|
|
286
332
|
if (existing && !existing.closed) {
|
|
287
333
|
if (existing.idleTimeout) clearTimeout(existing.idleTimeout);
|
|
288
|
-
return existing
|
|
334
|
+
return existing;
|
|
289
335
|
}
|
|
290
336
|
|
|
291
|
-
const
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
if (useSFTP) {
|
|
298
|
-
client.on('end', onClose);
|
|
299
|
-
client.on('error', onClose);
|
|
300
|
-
client.on('close', onClose);
|
|
301
|
-
} else {
|
|
302
|
-
client.ftp.socket.on('close', onClose);
|
|
303
|
-
client.ftp.socket.on('error', onClose);
|
|
304
|
-
}
|
|
337
|
+
const connectPromise = (async () => {
|
|
338
|
+
try {
|
|
339
|
+
telemetry.reconnects++;
|
|
340
|
+
const useSFTP = isSFTP(config.host);
|
|
341
|
+
const client = useSFTP ? await connectSFTP(config) : await connectFTP(config);
|
|
342
|
+
telemetry.activeConnections++;
|
|
305
343
|
|
|
306
|
-
|
|
307
|
-
|
|
344
|
+
client._isSFTP = useSFTP;
|
|
345
|
+
|
|
346
|
+
// Silence MaxListenersExceededWarning during high-activity syncs/sessions
|
|
347
|
+
if (typeof client.setMaxListeners === 'function') {
|
|
348
|
+
client.setMaxListeners(100);
|
|
349
|
+
} else if (client.ftp && typeof client.ftp.socket.setMaxListeners === 'function') {
|
|
350
|
+
client.ftp.socket.setMaxListeners(100);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
const entry = {
|
|
354
|
+
client,
|
|
355
|
+
closed: false,
|
|
356
|
+
promiseQueue: Promise.resolve(),
|
|
357
|
+
async execute(task) {
|
|
358
|
+
// Use a simple promise chain to serialize operations on this client
|
|
359
|
+
const result = this.promiseQueue.then(() => task(this.client));
|
|
360
|
+
this.promiseQueue = result.catch(() => {}); // Continue queue even on error
|
|
361
|
+
return result;
|
|
362
|
+
}
|
|
363
|
+
};
|
|
364
|
+
|
|
365
|
+
const onClose = () => handleClientClose(poolKey);
|
|
366
|
+
|
|
367
|
+
if (useSFTP) {
|
|
368
|
+
client.on('end', onClose);
|
|
369
|
+
client.on('error', onClose);
|
|
370
|
+
client.on('close', onClose);
|
|
371
|
+
} else {
|
|
372
|
+
client.ftp.socket.on('close', onClose);
|
|
373
|
+
client.ftp.socket.on('error', onClose);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
connectionPool.set(poolKey, entry);
|
|
377
|
+
return entry;
|
|
378
|
+
} finally {
|
|
379
|
+
connectingPromises.delete(poolKey);
|
|
380
|
+
}
|
|
381
|
+
})();
|
|
382
|
+
|
|
383
|
+
connectingPromises.set(poolKey, connectPromise);
|
|
384
|
+
return connectPromise;
|
|
308
385
|
}
|
|
309
386
|
|
|
310
387
|
function handleClientClose(poolKey) {
|
|
@@ -312,7 +389,30 @@ function handleClientClose(poolKey) {
|
|
|
312
389
|
if (existing) {
|
|
313
390
|
existing.closed = true;
|
|
314
391
|
connectionPool.delete(poolKey);
|
|
392
|
+
telemetry.activeConnections = Math.max(0, telemetry.activeConnections - 1);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function redactSecrets(obj) {
|
|
397
|
+
if (!obj) return obj;
|
|
398
|
+
if (typeof obj === 'string') {
|
|
399
|
+
return obj.replace(/(password|secret|key|token|passphrase)["':\s=]+[^\s,;}"']+/gi, '$1: [REDACTED]');
|
|
400
|
+
}
|
|
401
|
+
if (typeof obj !== 'object') return obj;
|
|
402
|
+
|
|
403
|
+
const redacted = Array.isArray(obj) ? [] : {};
|
|
404
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
405
|
+
if (key.toLowerCase().match(/(password|secret|key|token|passphrase)/)) {
|
|
406
|
+
redacted[key] = '[REDACTED]';
|
|
407
|
+
} else if (typeof value === 'object') {
|
|
408
|
+
redacted[key] = redactSecrets(value);
|
|
409
|
+
} else if (typeof value === 'string') {
|
|
410
|
+
redacted[key] = redactSecrets(value);
|
|
411
|
+
} else {
|
|
412
|
+
redacted[key] = value;
|
|
413
|
+
}
|
|
315
414
|
}
|
|
415
|
+
return redacted;
|
|
316
416
|
}
|
|
317
417
|
|
|
318
418
|
async function auditLog(toolName, args, status, user, errorMsg = null) {
|
|
@@ -322,8 +422,8 @@ async function auditLog(toolName, args, status, user, errorMsg = null) {
|
|
|
322
422
|
tool: toolName,
|
|
323
423
|
user: user || 'system',
|
|
324
424
|
status,
|
|
325
|
-
arguments: args,
|
|
326
|
-
error: errorMsg
|
|
425
|
+
arguments: redactSecrets(args),
|
|
426
|
+
error: redactSecrets(errorMsg)
|
|
327
427
|
};
|
|
328
428
|
await fs.appendFile(path.join(process.cwd(), '.ftp-mcp-audit.log'), JSON.stringify(logEntry) + '\n', 'utf8');
|
|
329
429
|
} catch (e) {
|
|
@@ -341,7 +441,7 @@ function releaseClient(config) {
|
|
|
341
441
|
try {
|
|
342
442
|
if (existing.client._isSFTP) await existing.client.end();
|
|
343
443
|
else existing.client.close();
|
|
344
|
-
} catch (e) {}
|
|
444
|
+
} catch (e) { }
|
|
345
445
|
connectionPool.delete(poolKey);
|
|
346
446
|
}, 60000).unref();
|
|
347
447
|
}
|
|
@@ -349,15 +449,15 @@ function releaseClient(config) {
|
|
|
349
449
|
|
|
350
450
|
async function getTreeRecursive(client, useSFTP, remotePath, depth = 0, maxDepth = 10) {
|
|
351
451
|
if (depth > maxDepth) return [];
|
|
352
|
-
|
|
452
|
+
|
|
353
453
|
const files = useSFTP ? await client.list(remotePath) : await client.list(remotePath);
|
|
354
454
|
const results = [];
|
|
355
|
-
|
|
455
|
+
|
|
356
456
|
for (const file of files) {
|
|
357
457
|
const isDir = useSFTP ? file.type === 'd' : file.isDirectory;
|
|
358
458
|
const fileName = file.name;
|
|
359
459
|
const fullPath = remotePath === '.' ? fileName : `${remotePath}/${fileName}`;
|
|
360
|
-
|
|
460
|
+
|
|
361
461
|
results.push({
|
|
362
462
|
path: fullPath,
|
|
363
463
|
name: fileName,
|
|
@@ -365,40 +465,52 @@ async function getTreeRecursive(client, useSFTP, remotePath, depth = 0, maxDepth
|
|
|
365
465
|
size: file.size,
|
|
366
466
|
modified: file.modifyTime || file.date
|
|
367
467
|
});
|
|
368
|
-
|
|
468
|
+
|
|
369
469
|
if (isDir && fileName !== '.' && fileName !== '..') {
|
|
370
470
|
const children = await getTreeRecursive(client, useSFTP, fullPath, depth + 1, maxDepth);
|
|
371
471
|
results.push(...children);
|
|
372
472
|
}
|
|
373
473
|
}
|
|
374
|
-
|
|
474
|
+
|
|
375
475
|
return results;
|
|
376
476
|
}
|
|
377
477
|
|
|
378
|
-
async function syncFiles(client, useSFTP, localPath, remotePath, direction, ignorePatterns = null, basePath = null, extraExclude = [], dryRun = false) {
|
|
379
|
-
const stats = { uploaded: 0, downloaded: 0, skipped: 0, errors: [], ignored: 0 };
|
|
380
|
-
|
|
478
|
+
async function syncFiles(client, useSFTP, localPath, remotePath, direction, ignorePatterns = null, basePath = null, extraExclude = [], dryRun = false, useManifest = true) {
|
|
479
|
+
const stats = { uploaded: 0, downloaded: 0, skipped: 0, errors: [], ignored: 0, filesToChange: [] };
|
|
480
|
+
|
|
381
481
|
if (ignorePatterns === null) {
|
|
382
482
|
ignorePatterns = await loadIgnorePatterns(localPath);
|
|
383
483
|
basePath = localPath;
|
|
484
|
+
if (useManifest) await syncManifestManager.load();
|
|
384
485
|
}
|
|
385
|
-
|
|
486
|
+
|
|
386
487
|
if (extraExclude.length > 0) {
|
|
387
488
|
ignorePatterns = [...ignorePatterns, ...extraExclude];
|
|
388
489
|
}
|
|
389
|
-
|
|
490
|
+
|
|
390
491
|
if (direction === 'upload' || direction === 'both') {
|
|
391
492
|
const localFiles = await fs.readdir(localPath, { withFileTypes: true });
|
|
392
|
-
|
|
493
|
+
|
|
393
494
|
for (const file of localFiles) {
|
|
394
495
|
const localFilePath = path.join(localPath, file.name);
|
|
395
496
|
const remoteFilePath = `${remotePath}/${file.name}`;
|
|
396
|
-
|
|
497
|
+
|
|
498
|
+
// In some environments (like Windows with ftp-srv), rapid transfers cause ECONNRESET.
|
|
499
|
+
// A slightly longer delay helps stabilize the socket state during sequence.
|
|
500
|
+
await new Promise(r => setTimeout(r, 250));
|
|
501
|
+
|
|
502
|
+
// Security check first so we can warn even if it's in .gitignore/.ftpignore
|
|
503
|
+
if (isSecretFile(localFilePath)) {
|
|
504
|
+
if (dryRun) stats.filesToChange.push(localFilePath);
|
|
505
|
+
stats.errors.push(`Security Warning: Blocked upload of likely secret file: ${localFilePath}`);
|
|
506
|
+
continue;
|
|
507
|
+
}
|
|
508
|
+
|
|
397
509
|
if (shouldIgnore(localFilePath, ignorePatterns, basePath)) {
|
|
398
510
|
stats.ignored++;
|
|
399
511
|
continue;
|
|
400
512
|
}
|
|
401
|
-
|
|
513
|
+
|
|
402
514
|
try {
|
|
403
515
|
if (file.isDirectory()) {
|
|
404
516
|
if (!dryRun) {
|
|
@@ -415,34 +527,73 @@ async function syncFiles(client, useSFTP, localPath, remotePath, direction, igno
|
|
|
415
527
|
stats.ignored += subStats.ignored;
|
|
416
528
|
stats.errors.push(...subStats.errors);
|
|
417
529
|
} else {
|
|
530
|
+
// isSecretFile already checked above in the loop
|
|
418
531
|
const localStat = await fs.stat(localFilePath);
|
|
419
532
|
let shouldUpload = true;
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
533
|
+
|
|
534
|
+
// 1. Fast check using local manifest
|
|
535
|
+
if (useManifest) {
|
|
536
|
+
const changedLocally = await syncManifestManager.isFileChanged(localFilePath, remoteFilePath, localStat);
|
|
537
|
+
if (!changedLocally) {
|
|
538
|
+
shouldUpload = false;
|
|
539
|
+
stats.skipped++;
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
// 2. Slow check using remote stat
|
|
544
|
+
if (shouldUpload) {
|
|
545
|
+
try {
|
|
546
|
+
const remoteStat = useSFTP
|
|
547
|
+
? await client.stat(remoteFilePath)
|
|
548
|
+
: (await client.list(remotePath)).find(f => f.name === file.name);
|
|
549
|
+
|
|
550
|
+
if (remoteStat) {
|
|
551
|
+
const remoteTime = remoteStat.modifyTime || remoteStat.modifiedAt || new Date(remoteStat.rawModifiedAt);
|
|
552
|
+
if (localStat.mtime <= remoteTime) {
|
|
553
|
+
shouldUpload = false;
|
|
554
|
+
stats.skipped++;
|
|
555
|
+
if (useManifest) await syncManifestManager.updateEntry(localFilePath, remoteFilePath, localStat);
|
|
556
|
+
}
|
|
431
557
|
}
|
|
558
|
+
} catch (e) {
|
|
559
|
+
// Remote file missing
|
|
432
560
|
}
|
|
433
|
-
} catch (e) {
|
|
434
|
-
// File doesn't exist remotely, upload it
|
|
435
561
|
}
|
|
436
|
-
|
|
562
|
+
|
|
437
563
|
if (shouldUpload) {
|
|
438
564
|
if (!dryRun) {
|
|
439
|
-
|
|
440
|
-
|
|
565
|
+
let attempts = 0;
|
|
566
|
+
const maxAttempts = 3;
|
|
567
|
+
let success = false;
|
|
568
|
+
let lastError;
|
|
569
|
+
|
|
570
|
+
while (attempts < maxAttempts && !success) {
|
|
571
|
+
try {
|
|
572
|
+
if (useSFTP) {
|
|
573
|
+
await client.put(localFilePath, remoteFilePath);
|
|
574
|
+
} else {
|
|
575
|
+
await client.uploadFrom(localFilePath, remoteFilePath);
|
|
576
|
+
}
|
|
577
|
+
success = true;
|
|
578
|
+
} catch (err) {
|
|
579
|
+
attempts++;
|
|
580
|
+
lastError = err;
|
|
581
|
+
if (attempts < maxAttempts) {
|
|
582
|
+
await new Promise(r => setTimeout(r, 100 * attempts)); // Backoff
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
if (success) {
|
|
588
|
+
if (useManifest) await syncManifestManager.updateEntry(localFilePath, remoteFilePath, localStat);
|
|
589
|
+
stats.uploaded++;
|
|
441
590
|
} else {
|
|
442
|
-
|
|
591
|
+
stats.errors.push(`${localFilePath}: Failed after ${maxAttempts} attempts: ${lastError.message}`);
|
|
443
592
|
}
|
|
593
|
+
} else {
|
|
594
|
+
stats.filesToChange.push(localFilePath);
|
|
595
|
+
stats.uploaded++;
|
|
444
596
|
}
|
|
445
|
-
stats.uploaded++;
|
|
446
597
|
}
|
|
447
598
|
}
|
|
448
599
|
} catch (error) {
|
|
@@ -450,14 +601,63 @@ async function syncFiles(client, useSFTP, localPath, remotePath, direction, igno
|
|
|
450
601
|
}
|
|
451
602
|
}
|
|
452
603
|
}
|
|
453
|
-
|
|
604
|
+
|
|
605
|
+
if (ignorePatterns === null && useManifest && !dryRun) {
|
|
606
|
+
await syncManifestManager.save();
|
|
607
|
+
}
|
|
608
|
+
|
|
454
609
|
return stats;
|
|
455
610
|
}
|
|
456
611
|
|
|
612
|
+
function generateSemanticPreview(filesToChange) {
|
|
613
|
+
const summary = {
|
|
614
|
+
configFiles: [],
|
|
615
|
+
dependencyManifests: [],
|
|
616
|
+
riskyFiles: [],
|
|
617
|
+
restartRequired: [],
|
|
618
|
+
otherFiles: []
|
|
619
|
+
};
|
|
620
|
+
|
|
621
|
+
for (const file of filesToChange) {
|
|
622
|
+
const name = path.basename(file).toLowerCase();
|
|
623
|
+
|
|
624
|
+
if (name === 'package.json' || name === 'composer.json' || name === 'requirements.txt' || name === 'pom.xml' || name === 'go.mod') {
|
|
625
|
+
summary.dependencyManifests.push(file);
|
|
626
|
+
summary.restartRequired.push(file);
|
|
627
|
+
} else if (name.endsWith('.config.js') || name.endsWith('.config.ts') || name === 'tsconfig.json' || name === 'vite.config.js' || name === 'next.config.js') {
|
|
628
|
+
summary.configFiles.push(file);
|
|
629
|
+
summary.restartRequired.push(file);
|
|
630
|
+
} else if (isSecretFile(file) || name.includes('auth') || name.includes('deploy')) {
|
|
631
|
+
summary.riskyFiles.push(file);
|
|
632
|
+
} else {
|
|
633
|
+
summary.otherFiles.push(file);
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
let output = "Semantic Change Preview:\n========================\n";
|
|
638
|
+
|
|
639
|
+
if (summary.dependencyManifests.length > 0) {
|
|
640
|
+
output += `\n📦 Dependency Manifests Changed (High Impact):\n - ${summary.dependencyManifests.join('\n - ')}\n`;
|
|
641
|
+
}
|
|
642
|
+
if (summary.configFiles.length > 0) {
|
|
643
|
+
output += `\n⚙️ Config Files Changed:\n - ${summary.configFiles.join('\n - ')}\n`;
|
|
644
|
+
}
|
|
645
|
+
if (summary.riskyFiles.length > 0) {
|
|
646
|
+
output += `\n⚠️ Risky Files Touched (Secrets/Auth/Deploy):\n - ${summary.riskyFiles.join('\n - ')}\n`;
|
|
647
|
+
}
|
|
648
|
+
if (summary.restartRequired.length > 0) {
|
|
649
|
+
output += `\n🔄 Likely Restart Required due to changes in:\n - ${summary.restartRequired.join('\n - ')}\n`;
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
output += `\n📄 Other Files: ${summary.otherFiles.length} files\n`;
|
|
653
|
+
|
|
654
|
+
return output;
|
|
655
|
+
}
|
|
656
|
+
|
|
457
657
|
const server = new Server(
|
|
458
658
|
{
|
|
459
659
|
name: "ftp-mcp-server",
|
|
460
|
-
version: "2.0
|
|
660
|
+
version: "1.2.0",
|
|
461
661
|
},
|
|
462
662
|
{
|
|
463
663
|
capabilities: {
|
|
@@ -568,6 +768,15 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
568
768
|
patch: {
|
|
569
769
|
type: "string",
|
|
570
770
|
description: "Unified diff string containing the changes"
|
|
771
|
+
},
|
|
772
|
+
expectedHash: {
|
|
773
|
+
type: "string",
|
|
774
|
+
description: "Optional MD5 hash of the file before patching to prevent drift"
|
|
775
|
+
},
|
|
776
|
+
createBackup: {
|
|
777
|
+
type: "boolean",
|
|
778
|
+
description: "Create a .bak file before patching",
|
|
779
|
+
default: true
|
|
571
780
|
}
|
|
572
781
|
},
|
|
573
782
|
required: ["path", "patch"]
|
|
@@ -654,13 +863,26 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
654
863
|
},
|
|
655
864
|
{
|
|
656
865
|
name: "ftp_search",
|
|
657
|
-
description: "
|
|
866
|
+
description: "Advanced remote search: find files by name, content, or type",
|
|
658
867
|
inputSchema: {
|
|
659
868
|
type: "object",
|
|
660
869
|
properties: {
|
|
661
870
|
pattern: {
|
|
662
871
|
type: "string",
|
|
663
|
-
description: "
|
|
872
|
+
description: "Filename search pattern (supports wildcards like *.js)"
|
|
873
|
+
},
|
|
874
|
+
contentPattern: {
|
|
875
|
+
type: "string",
|
|
876
|
+
description: "Regex pattern to search inside file contents (grep)"
|
|
877
|
+
},
|
|
878
|
+
extension: {
|
|
879
|
+
type: "string",
|
|
880
|
+
description: "Filter by file extension (e.g., '.js', '.php')"
|
|
881
|
+
},
|
|
882
|
+
findLikelyConfigs: {
|
|
883
|
+
type: "boolean",
|
|
884
|
+
description: "If true, prioritizes finding config, auth, and build files",
|
|
885
|
+
default: false
|
|
664
886
|
},
|
|
665
887
|
path: {
|
|
666
888
|
type: "string",
|
|
@@ -677,8 +899,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
677
899
|
description: "Results to skip over",
|
|
678
900
|
default: 0
|
|
679
901
|
}
|
|
680
|
-
}
|
|
681
|
-
required: ["pattern"]
|
|
902
|
+
}
|
|
682
903
|
}
|
|
683
904
|
},
|
|
684
905
|
{
|
|
@@ -767,6 +988,11 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
767
988
|
type: "boolean",
|
|
768
989
|
description: "If true, simulates the sync without transferring files",
|
|
769
990
|
default: false
|
|
991
|
+
},
|
|
992
|
+
useManifest: {
|
|
993
|
+
type: "boolean",
|
|
994
|
+
description: "Use local manifest cache for faster deploys (drift-aware)",
|
|
995
|
+
default: true
|
|
770
996
|
}
|
|
771
997
|
},
|
|
772
998
|
required: ["localPath", "remotePath"]
|
|
@@ -904,6 +1130,50 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
904
1130
|
},
|
|
905
1131
|
required: ["oldPath", "newPath"]
|
|
906
1132
|
}
|
|
1133
|
+
},
|
|
1134
|
+
{
|
|
1135
|
+
name: "ftp_rollback",
|
|
1136
|
+
description: "Rollback a previous transaction using its snapshot",
|
|
1137
|
+
inputSchema: {
|
|
1138
|
+
type: "object",
|
|
1139
|
+
properties: {
|
|
1140
|
+
transactionId: {
|
|
1141
|
+
type: "string",
|
|
1142
|
+
description: "Transaction ID to rollback (e.g., tx_1234567890_abcd)"
|
|
1143
|
+
}
|
|
1144
|
+
},
|
|
1145
|
+
required: ["transactionId"]
|
|
1146
|
+
}
|
|
1147
|
+
},
|
|
1148
|
+
{
|
|
1149
|
+
name: "ftp_list_transactions",
|
|
1150
|
+
description: "List available rollback transactions",
|
|
1151
|
+
inputSchema: {
|
|
1152
|
+
type: "object",
|
|
1153
|
+
properties: {}
|
|
1154
|
+
}
|
|
1155
|
+
},
|
|
1156
|
+
{
|
|
1157
|
+
name: "ftp_probe_capabilities",
|
|
1158
|
+
description: "Probe the server to detect supported features (chmod, symlinks, disk space, etc.)",
|
|
1159
|
+
inputSchema: {
|
|
1160
|
+
type: "object",
|
|
1161
|
+
properties: {
|
|
1162
|
+
testPath: {
|
|
1163
|
+
type: "string",
|
|
1164
|
+
description: "A safe remote directory to run tests in (defaults to current directory)",
|
|
1165
|
+
default: "."
|
|
1166
|
+
}
|
|
1167
|
+
}
|
|
1168
|
+
}
|
|
1169
|
+
},
|
|
1170
|
+
{
|
|
1171
|
+
name: "ftp_telemetry",
|
|
1172
|
+
description: "Get connection health and performance telemetry",
|
|
1173
|
+
inputSchema: {
|
|
1174
|
+
type: "object",
|
|
1175
|
+
properties: {}
|
|
1176
|
+
}
|
|
907
1177
|
}
|
|
908
1178
|
]
|
|
909
1179
|
};
|
|
@@ -915,7 +1185,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
915
1185
|
const configPath = path.join(process.cwd(), '.ftpconfig');
|
|
916
1186
|
const configData = await fs.readFile(configPath, 'utf8');
|
|
917
1187
|
const config = JSON.parse(configData);
|
|
918
|
-
|
|
1188
|
+
|
|
919
1189
|
if (!config.deployments || Object.keys(config.deployments).length === 0) {
|
|
920
1190
|
return {
|
|
921
1191
|
content: [{
|
|
@@ -924,11 +1194,11 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
924
1194
|
}]
|
|
925
1195
|
};
|
|
926
1196
|
}
|
|
927
|
-
|
|
1197
|
+
|
|
928
1198
|
const deploymentList = Object.entries(config.deployments).map(([name, deploy]) => {
|
|
929
1199
|
return `${name}\n Profile: ${deploy.profile}\n Local: ${deploy.local}\n Remote: ${deploy.remote}\n Description: ${deploy.description || 'N/A'}`;
|
|
930
1200
|
}).join('\n\n');
|
|
931
|
-
|
|
1201
|
+
|
|
932
1202
|
return {
|
|
933
1203
|
content: [{
|
|
934
1204
|
type: "text",
|
|
@@ -949,7 +1219,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
949
1219
|
const configPath = path.join(process.cwd(), '.ftpconfig');
|
|
950
1220
|
const configData = await fs.readFile(configPath, 'utf8');
|
|
951
1221
|
const config = JSON.parse(configData);
|
|
952
|
-
|
|
1222
|
+
|
|
953
1223
|
if (!config.deployments || !config.deployments[deployment]) {
|
|
954
1224
|
return {
|
|
955
1225
|
content: [{
|
|
@@ -959,10 +1229,10 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
959
1229
|
isError: true
|
|
960
1230
|
};
|
|
961
1231
|
}
|
|
962
|
-
|
|
1232
|
+
|
|
963
1233
|
const deployConfig = config.deployments[deployment];
|
|
964
1234
|
const profileConfig = config[deployConfig.profile];
|
|
965
|
-
|
|
1235
|
+
|
|
966
1236
|
if (!profileConfig) {
|
|
967
1237
|
return {
|
|
968
1238
|
content: [{
|
|
@@ -972,26 +1242,26 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
972
1242
|
isError: true
|
|
973
1243
|
};
|
|
974
1244
|
}
|
|
975
|
-
|
|
1245
|
+
|
|
976
1246
|
currentConfig = profileConfig;
|
|
977
1247
|
currentProfile = deployConfig.profile;
|
|
978
|
-
|
|
1248
|
+
|
|
979
1249
|
const useSFTP = isSFTP(currentConfig.host);
|
|
980
1250
|
const client = await getClient(currentConfig);
|
|
981
|
-
|
|
1251
|
+
|
|
982
1252
|
try {
|
|
983
1253
|
const localPath = path.resolve(deployConfig.local);
|
|
984
1254
|
const stats = await syncFiles(
|
|
985
|
-
client,
|
|
986
|
-
useSFTP,
|
|
987
|
-
localPath,
|
|
988
|
-
deployConfig.remote,
|
|
1255
|
+
client,
|
|
1256
|
+
useSFTP,
|
|
1257
|
+
localPath,
|
|
1258
|
+
deployConfig.remote,
|
|
989
1259
|
'upload',
|
|
990
1260
|
null,
|
|
991
1261
|
null,
|
|
992
1262
|
deployConfig.exclude || []
|
|
993
1263
|
);
|
|
994
|
-
|
|
1264
|
+
|
|
995
1265
|
return {
|
|
996
1266
|
content: [{
|
|
997
1267
|
type: "text",
|
|
@@ -1013,7 +1283,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1013
1283
|
try {
|
|
1014
1284
|
const { profile, useEnv } = request.params.arguments || {};
|
|
1015
1285
|
currentConfig = await loadFTPConfig(profile, useEnv);
|
|
1016
|
-
|
|
1286
|
+
const poolKey = getPoolKey(currentConfig);
|
|
1287
|
+
invalidatePoolCache(poolKey);
|
|
1288
|
+
|
|
1017
1289
|
if (!currentConfig.host || !currentConfig.user) {
|
|
1018
1290
|
return {
|
|
1019
1291
|
content: [
|
|
@@ -1024,11 +1296,17 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1024
1296
|
]
|
|
1025
1297
|
};
|
|
1026
1298
|
}
|
|
1027
|
-
|
|
1299
|
+
|
|
1300
|
+
let warning = "";
|
|
1301
|
+
const isProd = (profile || currentProfile || '').toLowerCase().includes('prod');
|
|
1302
|
+
if (isProd && !isSFTP(currentConfig.host)) {
|
|
1303
|
+
warning = "\n⚠️ SECURITY WARNING: You are connecting to a production profile using insecure FTP. SFTP is strongly recommended.";
|
|
1304
|
+
}
|
|
1305
|
+
|
|
1028
1306
|
return {
|
|
1029
1307
|
content: [{
|
|
1030
1308
|
type: "text",
|
|
1031
|
-
text: `Connected to profile: ${profile || currentProfile || 'environment variables'}\nHost: ${currentConfig.host}`
|
|
1309
|
+
text: `Connected to profile: ${profile || currentProfile || 'environment variables'}\nHost: ${currentConfig.host}${warning}`
|
|
1032
1310
|
}]
|
|
1033
1311
|
};
|
|
1034
1312
|
} catch (error) {
|
|
@@ -1049,560 +1327,880 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1049
1327
|
type: "text",
|
|
1050
1328
|
text: "Error: FTP credentials not configured. Please use ftp_connect first or set environment variables."
|
|
1051
1329
|
}
|
|
1052
|
-
]
|
|
1330
|
+
],
|
|
1331
|
+
isError: true
|
|
1053
1332
|
};
|
|
1054
1333
|
}
|
|
1055
1334
|
}
|
|
1056
1335
|
|
|
1057
|
-
if (!currentConfig.host || !currentConfig.user) {
|
|
1336
|
+
if (!currentConfig || !currentConfig.host || !currentConfig.user) {
|
|
1058
1337
|
return {
|
|
1059
1338
|
content: [
|
|
1060
1339
|
{
|
|
1061
1340
|
type: "text",
|
|
1062
1341
|
text: "Error: FTP credentials not configured. Please set FTPMCP_HOST, FTPMCP_USER, and FTPMCP_PASSWORD environment variables or create a .ftpconfig file."
|
|
1063
1342
|
}
|
|
1064
|
-
]
|
|
1343
|
+
],
|
|
1344
|
+
isError: true
|
|
1065
1345
|
};
|
|
1066
1346
|
}
|
|
1067
1347
|
|
|
1068
|
-
const useSFTP = isSFTP(currentConfig.host);
|
|
1069
|
-
let client;
|
|
1070
|
-
|
|
1071
1348
|
try {
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1349
|
+
const entry = await getClient(currentConfig);
|
|
1350
|
+
const client = entry.client;
|
|
1351
|
+
const useSFTP = client._isSFTP;
|
|
1352
|
+
const poolKey = getPoolKey(currentConfig);
|
|
1353
|
+
const cmdName = request.params.name;
|
|
1354
|
+
const isDestructive = ["ftp_deploy", "ftp_put_contents", "ftp_batch_upload", "ftp_sync", "ftp_upload", "ftp_delete", "ftp_mkdir", "ftp_rmdir", "ftp_chmod", "ftp_rename", "ftp_copy", "ftp_patch_file"].includes(cmdName);
|
|
1355
|
+
|
|
1356
|
+
const policyEngine = new PolicyEngine(currentConfig || {});
|
|
1357
|
+
|
|
1358
|
+
if (isDestructive) {
|
|
1359
|
+
if (currentConfig.readOnly) {
|
|
1360
|
+
const errorResp = {
|
|
1361
|
+
content: [{ type: "text", text: `Error: Profile '${currentProfile}' is configured in readOnly mode. Destructive actions are disabled.` }],
|
|
1362
|
+
isError: true
|
|
1363
|
+
};
|
|
1364
|
+
await auditLog(cmdName, request.params.arguments, 'failed', currentProfile, 'readOnly mode violation');
|
|
1365
|
+
return errorResp;
|
|
1087
1366
|
}
|
|
1367
|
+
invalidatePoolCache(poolKey);
|
|
1368
|
+
}
|
|
1088
1369
|
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
const total = files.length;
|
|
1104
|
-
const sliced = files.slice(offset, offset + limit);
|
|
1105
|
-
|
|
1106
|
-
const formatted = sliced.map(f => {
|
|
1107
|
-
const type = (useSFTP ? f.type === 'd' : f.isDirectory) ? 'DIR ' : 'FILE';
|
|
1108
|
-
const rights = useSFTP && f.rights ? `, ${f.rights.user || ''}${f.rights.group || ''}${f.rights.other || ''}` : '';
|
|
1109
|
-
return `${type} ${f.name} (${f.size} bytes${rights})`;
|
|
1110
|
-
}).join('\n');
|
|
1111
|
-
|
|
1112
|
-
const paginationInfo = `\n\nShowing ${offset + 1} to ${Math.min(offset + limit, total)} of ${total} items.`;
|
|
1113
|
-
return {
|
|
1114
|
-
content: [{ type: "text", text: (formatted || "Empty directory") + (total > limit ? paginationInfo : '') }]
|
|
1115
|
-
};
|
|
1116
|
-
}
|
|
1370
|
+
const response = await entry.execute(async (client) => {
|
|
1371
|
+
switch (cmdName) {
|
|
1372
|
+
case "ftp_list": {
|
|
1373
|
+
const path = request.params.arguments?.path || ".";
|
|
1374
|
+
const limit = request.params.arguments?.limit || 100;
|
|
1375
|
+
const offset = request.params.arguments?.offset || 0;
|
|
1376
|
+
|
|
1377
|
+
let files = getCached(poolKey, 'LIST', path);
|
|
1378
|
+
if (!files) {
|
|
1379
|
+
files = useSFTP ? await client.list(path) : await client.list(path);
|
|
1380
|
+
files.sort((a, b) => a.name.localeCompare(b.name));
|
|
1381
|
+
setCached(poolKey, 'LIST', path, files);
|
|
1382
|
+
}
|
|
1117
1383
|
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
const
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
}
|
|
1132
|
-
});
|
|
1133
|
-
|
|
1134
|
-
await client.downloadTo(stream, path);
|
|
1135
|
-
content = Buffer.concat(chunks).toString('utf8');
|
|
1136
|
-
}
|
|
1137
|
-
|
|
1138
|
-
if (startLine || endLine) {
|
|
1139
|
-
const lines = content.split('\n');
|
|
1140
|
-
const start = Math.max((startLine || 1) - 1, 0);
|
|
1141
|
-
const end = endLine ? Math.min(endLine, lines.length) : lines.length;
|
|
1142
|
-
const totalLength = lines.length;
|
|
1143
|
-
|
|
1144
|
-
content = lines.slice(start, end).join('\n');
|
|
1145
|
-
content = `... Showing lines ${start + 1} to ${end} of ${totalLength} ...\n${content}`;
|
|
1384
|
+
const total = files.length;
|
|
1385
|
+
const sliced = files.slice(offset, offset + limit);
|
|
1386
|
+
|
|
1387
|
+
const formatted = sliced.map(f => {
|
|
1388
|
+
const type = (useSFTP ? f.type === 'd' : f.isDirectory) ? 'DIR ' : 'FILE';
|
|
1389
|
+
const rights = useSFTP && f.rights ? `, ${f.rights.user || ''}${f.rights.group || ''}${f.rights.other || ''}` : '';
|
|
1390
|
+
return `${type} ${f.name} (${f.size} bytes${rights})`;
|
|
1391
|
+
}).join('\n');
|
|
1392
|
+
|
|
1393
|
+
const paginationInfo = `\n\nShowing ${offset + 1} to ${Math.min(offset + limit, total)} of ${total} items.`;
|
|
1394
|
+
return {
|
|
1395
|
+
content: [{ type: "text", text: (formatted || "Empty directory") + (total > limit ? paginationInfo : '') }]
|
|
1396
|
+
};
|
|
1146
1397
|
}
|
|
1147
|
-
|
|
1148
|
-
return {
|
|
1149
|
-
content: [{ type: "text", text: content }]
|
|
1150
|
-
};
|
|
1151
|
-
}
|
|
1152
1398
|
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
try {
|
|
1399
|
+
case "ftp_get_contents": {
|
|
1400
|
+
const { path, startLine, endLine } = request.params.arguments;
|
|
1401
|
+
let content;
|
|
1402
|
+
|
|
1158
1403
|
if (useSFTP) {
|
|
1159
1404
|
const buffer = await client.get(path);
|
|
1160
1405
|
content = buffer.toString('utf8');
|
|
1161
1406
|
} else {
|
|
1162
1407
|
const chunks = [];
|
|
1163
|
-
const stream = new Writable({
|
|
1408
|
+
const stream = new Writable({
|
|
1409
|
+
write(chunk, encoding, callback) {
|
|
1410
|
+
chunks.push(chunk);
|
|
1411
|
+
callback();
|
|
1412
|
+
}
|
|
1413
|
+
});
|
|
1414
|
+
|
|
1164
1415
|
await client.downloadTo(stream, path);
|
|
1165
1416
|
content = Buffer.concat(chunks).toString('utf8');
|
|
1166
1417
|
}
|
|
1167
|
-
} catch (e) {
|
|
1168
|
-
return {
|
|
1169
|
-
content: [{ type: "text", text: `Error: File not found or unreadable. ${e.message}` }],
|
|
1170
|
-
isError: true
|
|
1171
|
-
};
|
|
1172
|
-
}
|
|
1173
|
-
|
|
1174
|
-
const patchedContent = diff.applyPatch(content, patch);
|
|
1175
|
-
if (patchedContent === false) {
|
|
1176
|
-
return {
|
|
1177
|
-
content: [{ type: "text", text: `Error: Failed to apply patch cleanly. Diff may be malformed or out of date with remote file.` }],
|
|
1178
|
-
isError: true
|
|
1179
|
-
};
|
|
1180
|
-
}
|
|
1181
|
-
|
|
1182
|
-
if (useSFTP) {
|
|
1183
|
-
const buffer = Buffer.from(patchedContent, 'utf8');
|
|
1184
|
-
await client.put(buffer, path);
|
|
1185
|
-
} else {
|
|
1186
|
-
const readable = Readable.from([patchedContent]);
|
|
1187
|
-
await client.uploadFrom(readable, path);
|
|
1188
|
-
}
|
|
1189
|
-
|
|
1190
|
-
return {
|
|
1191
|
-
content: [{ type: "text", text: `Successfully patched ${path}` }]
|
|
1192
|
-
};
|
|
1193
|
-
}
|
|
1194
1418
|
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
await client.put(buffer, path);
|
|
1201
|
-
} else {
|
|
1202
|
-
const readable = Readable.from([content]);
|
|
1203
|
-
await client.uploadFrom(readable, path);
|
|
1204
|
-
}
|
|
1205
|
-
|
|
1206
|
-
return {
|
|
1207
|
-
content: [{ type: "text", text: `Successfully wrote content to ${path}` }]
|
|
1208
|
-
};
|
|
1209
|
-
}
|
|
1419
|
+
if (startLine || endLine) {
|
|
1420
|
+
const lines = content.split('\n');
|
|
1421
|
+
const start = Math.max((startLine || 1) - 1, 0);
|
|
1422
|
+
const end = endLine ? Math.min(endLine, lines.length) : lines.length;
|
|
1423
|
+
const totalLength = lines.length;
|
|
1210
1424
|
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
if (useSFTP) {
|
|
1215
|
-
const stats = await client.stat(path);
|
|
1216
|
-
return {
|
|
1217
|
-
content: [{
|
|
1218
|
-
type: "text",
|
|
1219
|
-
text: JSON.stringify({
|
|
1220
|
-
size: stats.size,
|
|
1221
|
-
modified: stats.modifyTime,
|
|
1222
|
-
accessed: stats.accessTime,
|
|
1223
|
-
permissions: stats.mode,
|
|
1224
|
-
isDirectory: stats.isDirectory,
|
|
1225
|
-
isFile: stats.isFile
|
|
1226
|
-
}, null, 2)
|
|
1227
|
-
}]
|
|
1228
|
-
};
|
|
1229
|
-
} else {
|
|
1230
|
-
const dirPath = path.substring(0, path.lastIndexOf('/')) || '.';
|
|
1231
|
-
const fileName = path.substring(path.lastIndexOf('/') + 1);
|
|
1232
|
-
const files = await client.list(dirPath);
|
|
1233
|
-
const file = files.find(f => f.name === fileName);
|
|
1234
|
-
|
|
1235
|
-
if (!file) {
|
|
1236
|
-
throw new Error(`File not found: ${path}`);
|
|
1425
|
+
content = lines.slice(start, end).join('\n');
|
|
1426
|
+
content = `... Showing lines ${start + 1} to ${end} of ${totalLength} ...\n${content}`;
|
|
1237
1427
|
}
|
|
1238
|
-
|
|
1428
|
+
|
|
1239
1429
|
return {
|
|
1240
|
-
content: [{
|
|
1241
|
-
type: "text",
|
|
1242
|
-
text: JSON.stringify({
|
|
1243
|
-
size: file.size,
|
|
1244
|
-
modified: file.modifiedAt || file.rawModifiedAt,
|
|
1245
|
-
isDirectory: file.isDirectory,
|
|
1246
|
-
isFile: file.isFile
|
|
1247
|
-
}, null, 2)
|
|
1248
|
-
}]
|
|
1430
|
+
content: [{ type: "text", text: content }]
|
|
1249
1431
|
};
|
|
1250
1432
|
}
|
|
1251
|
-
}
|
|
1252
|
-
|
|
1253
|
-
case "ftp_exists": {
|
|
1254
|
-
const { path } = request.params.arguments;
|
|
1255
|
-
let exists = false;
|
|
1256
|
-
|
|
1257
|
-
try {
|
|
1258
|
-
if (useSFTP) {
|
|
1259
|
-
await client.stat(path);
|
|
1260
|
-
exists = true;
|
|
1261
|
-
} else {
|
|
1262
|
-
const dirPath = path.substring(0, path.lastIndexOf('/')) || '.';
|
|
1263
|
-
const fileName = path.substring(path.lastIndexOf('/') + 1);
|
|
1264
|
-
const files = await client.list(dirPath);
|
|
1265
|
-
exists = files.some(f => f.name === fileName);
|
|
1266
|
-
}
|
|
1267
|
-
} catch (e) {
|
|
1268
|
-
exists = false;
|
|
1269
|
-
}
|
|
1270
|
-
|
|
1271
|
-
return {
|
|
1272
|
-
content: [{ type: "text", text: exists ? "true" : "false" }]
|
|
1273
|
-
};
|
|
1274
|
-
}
|
|
1275
1433
|
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
const cacheKey = `${path}:${maxDepth}`;
|
|
1279
|
-
let tree = getCached(poolKey, 'TREE', cacheKey);
|
|
1280
|
-
if (!tree) {
|
|
1281
|
-
tree = await getTreeRecursive(client, useSFTP, path, 0, maxDepth);
|
|
1282
|
-
setCached(poolKey, 'TREE', cacheKey, tree);
|
|
1283
|
-
}
|
|
1284
|
-
|
|
1285
|
-
const formatted = tree.map(item => {
|
|
1286
|
-
const indent = ' '.repeat((item.path.match(/\//g) || []).length);
|
|
1287
|
-
return `${indent}${item.isDirectory ? '📁' : '📄'} ${item.name} ${!item.isDirectory ? `(${item.size} bytes)` : ''}`;
|
|
1288
|
-
}).join('\n');
|
|
1289
|
-
|
|
1290
|
-
return {
|
|
1291
|
-
content: [{ type: "text", text: formatted || "Empty directory" }]
|
|
1292
|
-
};
|
|
1293
|
-
}
|
|
1434
|
+
case "ftp_patch_file": {
|
|
1435
|
+
const { path: filePath, patch, expectedHash, createBackup = true } = request.params.arguments;
|
|
1294
1436
|
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
tree = await getTreeRecursive(client, useSFTP, path, 0, 10);
|
|
1301
|
-
setCached(poolKey, 'TREE', cacheKey, tree);
|
|
1302
|
-
}
|
|
1303
|
-
|
|
1304
|
-
const regex = new RegExp(pattern.replace(/\*/g, '.*').replace(/\?/g, '.'));
|
|
1305
|
-
const matches = tree.filter(item => regex.test(item.name));
|
|
1306
|
-
|
|
1307
|
-
const total = matches.length;
|
|
1308
|
-
const sliced = matches.slice(offset, offset + limit);
|
|
1309
|
-
|
|
1310
|
-
const formatted = sliced.map(item =>
|
|
1311
|
-
`${item.path} (${item.isDirectory ? 'DIR' : item.size + ' bytes'})`
|
|
1312
|
-
).join('\n');
|
|
1313
|
-
|
|
1314
|
-
const paginationInfo = `\n\nShowing ${offset + 1} to ${Math.min(offset + limit, total)} of ${total} matches.`;
|
|
1315
|
-
return {
|
|
1316
|
-
content: [{ type: "text", text: (formatted || "No matches found") + (total > limit ? paginationInfo : '') }]
|
|
1317
|
-
};
|
|
1318
|
-
}
|
|
1437
|
+
try {
|
|
1438
|
+
policyEngine.validateOperation('patch', { path: filePath });
|
|
1439
|
+
} catch (e) {
|
|
1440
|
+
return { content: [{ type: "text", text: e.message }], isError: true };
|
|
1441
|
+
}
|
|
1319
1442
|
|
|
1320
|
-
|
|
1321
|
-
const path = request.params.arguments?.path || ".";
|
|
1322
|
-
let files = getCached(poolKey, 'LIST', path);
|
|
1323
|
-
if (!files) {
|
|
1324
|
-
files = useSFTP ? await client.list(path) : await client.list(path);
|
|
1325
|
-
setCached(poolKey, 'LIST', path, files);
|
|
1326
|
-
}
|
|
1327
|
-
|
|
1328
|
-
const importantFiles = ['package.json', 'composer.json', 'requirements.txt', 'pyproject.toml', 'Cargo.toml', 'go.mod', 'README.md'];
|
|
1329
|
-
const found = files.filter(f => importantFiles.includes(f.name));
|
|
1330
|
-
|
|
1331
|
-
let summary = `Workspace Analysis for: ${path}\n============================\n\n`;
|
|
1332
|
-
if (found.length === 0) {
|
|
1333
|
-
summary += "No recognizable project configuration files found.";
|
|
1334
|
-
return { content: [{ type: "text", text: summary }] };
|
|
1335
|
-
}
|
|
1336
|
-
|
|
1337
|
-
for (const file of found) {
|
|
1338
|
-
const filePath = path === "." ? file.name : `${path}/${file.name}`;
|
|
1443
|
+
let content;
|
|
1339
1444
|
try {
|
|
1340
|
-
let content;
|
|
1341
1445
|
if (useSFTP) {
|
|
1342
|
-
|
|
1446
|
+
const buffer = await client.get(filePath);
|
|
1447
|
+
content = buffer.toString('utf8');
|
|
1343
1448
|
} else {
|
|
1344
1449
|
const chunks = [];
|
|
1345
|
-
const stream = new Writable({ write(
|
|
1450
|
+
const stream = new Writable({ write(chunk, encoding, callback) { chunks.push(chunk); callback(); } });
|
|
1346
1451
|
await client.downloadTo(stream, filePath);
|
|
1347
1452
|
content = Buffer.concat(chunks).toString('utf8');
|
|
1348
1453
|
}
|
|
1349
|
-
|
|
1350
|
-
if (file.name === 'package.json' || file.name === 'composer.json') {
|
|
1351
|
-
const parsed = JSON.parse(content);
|
|
1352
|
-
summary += `[${file.name}]\nName: ${parsed.name || 'Unknown'}\nVersion: ${parsed.version || 'Unknown'}\n`;
|
|
1353
|
-
if (parsed.dependencies || parsed.require) summary += `Dependencies: ${Object.keys(parsed.dependencies || parsed.require).slice(0, 10).join(', ')}...\n`;
|
|
1354
|
-
} else if (file.name === 'README.md') {
|
|
1355
|
-
summary += `[README.md (Preview)]\n${content.split('\n').filter(l => l.trim()).slice(0, 5).join('\n')}\n`;
|
|
1356
|
-
} else {
|
|
1357
|
-
summary += `[${file.name}]\n${content.split('\n').slice(0, 10).join('\n')}...\n`;
|
|
1358
|
-
}
|
|
1359
|
-
summary += '\n';
|
|
1360
1454
|
} catch (e) {
|
|
1361
|
-
|
|
1455
|
+
return {
|
|
1456
|
+
content: [{ type: "text", text: `Error: File not found or unreadable. ${e.message}` }],
|
|
1457
|
+
isError: true
|
|
1458
|
+
};
|
|
1362
1459
|
}
|
|
1363
|
-
}
|
|
1364
|
-
|
|
1365
|
-
return {
|
|
1366
|
-
content: [{ type: "text", text: summary.trim() }]
|
|
1367
|
-
};
|
|
1368
|
-
}
|
|
1369
1460
|
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
const buffer = await client.get(sourcePath);
|
|
1380
|
-
await client.put(buffer, destPath);
|
|
1381
|
-
|
|
1382
|
-
return {
|
|
1383
|
-
content: [{ type: "text", text: `Successfully copied ${sourcePath} to ${destPath}` }]
|
|
1384
|
-
};
|
|
1385
|
-
}
|
|
1461
|
+
if (expectedHash) {
|
|
1462
|
+
const currentHash = crypto.createHash('md5').update(content).digest('hex');
|
|
1463
|
+
if (currentHash !== expectedHash) {
|
|
1464
|
+
return {
|
|
1465
|
+
content: [{ type: "text", text: `Error: File drift detected. Expected hash ${expectedHash}, but got ${currentHash}.` }],
|
|
1466
|
+
isError: true
|
|
1467
|
+
};
|
|
1468
|
+
}
|
|
1469
|
+
}
|
|
1386
1470
|
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1471
|
+
// Try exact match first
|
|
1472
|
+
let patchedContent = diff.applyPatch(content, patch);
|
|
1473
|
+
let confidence = 100;
|
|
1474
|
+
|
|
1475
|
+
// If exact match fails, try fuzzy match
|
|
1476
|
+
if (patchedContent === false) {
|
|
1477
|
+
patchedContent = diff.applyPatch(content, patch, { fuzzFactor: 2 });
|
|
1478
|
+
confidence = 50; // Arbitrary lower confidence for fuzzy match
|
|
1479
|
+
}
|
|
1480
|
+
|
|
1481
|
+
if (patchedContent === false) {
|
|
1482
|
+
return {
|
|
1483
|
+
content: [{ type: "text", text: `Error: Failed to apply patch cleanly. Diff may be malformed or out of date with remote file.` }],
|
|
1484
|
+
isError: true
|
|
1485
|
+
};
|
|
1486
|
+
}
|
|
1487
|
+
|
|
1488
|
+
const txId = await snapshotManager.createSnapshot(client, useSFTP, [filePath]);
|
|
1489
|
+
|
|
1490
|
+
if (createBackup) {
|
|
1491
|
+
const backupPath = `${filePath}.bak`;
|
|
1393
1492
|
if (useSFTP) {
|
|
1394
|
-
|
|
1493
|
+
const buffer = Buffer.from(content, 'utf8');
|
|
1494
|
+
await client.put(buffer, backupPath);
|
|
1395
1495
|
} else {
|
|
1396
|
-
|
|
1496
|
+
const readable = Readable.from([content]);
|
|
1497
|
+
await client.uploadFrom(readable, backupPath);
|
|
1397
1498
|
}
|
|
1398
|
-
results.success.push(file.remotePath);
|
|
1399
|
-
} catch (error) {
|
|
1400
|
-
results.failed.push({ path: file.remotePath, error: error.message });
|
|
1401
1499
|
}
|
|
1500
|
+
|
|
1501
|
+
if (useSFTP) {
|
|
1502
|
+
const buffer = Buffer.from(patchedContent, 'utf8');
|
|
1503
|
+
await client.put(buffer, filePath);
|
|
1504
|
+
} else {
|
|
1505
|
+
const readable = Readable.from([patchedContent]);
|
|
1506
|
+
await client.uploadFrom(readable, filePath);
|
|
1507
|
+
}
|
|
1508
|
+
|
|
1509
|
+
return {
|
|
1510
|
+
content: [{ type: "text", text: `Successfully patched ${filePath} (Confidence: ${confidence}%)\nTransaction ID: ${txId}${createBackup ? `\nBackup created at: ${filePath}.bak` : ''}` }]
|
|
1511
|
+
};
|
|
1402
1512
|
}
|
|
1403
|
-
|
|
1404
|
-
return {
|
|
1405
|
-
content: [{
|
|
1406
|
-
type: "text",
|
|
1407
|
-
text: `Uploaded: ${results.success.length}\nFailed: ${results.failed.length}\n${results.failed.length > 0 ? '\nErrors:\n' + results.failed.map(f => `${f.path}: ${f.error}`).join('\n') : ''}`
|
|
1408
|
-
}]
|
|
1409
|
-
};
|
|
1410
|
-
}
|
|
1411
1513
|
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1514
|
+
case "ftp_put_contents": {
|
|
1515
|
+
const { path: filePath, content } = request.params.arguments;
|
|
1516
|
+
|
|
1517
|
+
try {
|
|
1518
|
+
policyEngine.validateOperation('overwrite', { path: filePath, size: Buffer.byteLength(content, 'utf8') });
|
|
1519
|
+
} catch (e) {
|
|
1520
|
+
return { content: [{ type: "text", text: e.message }], isError: true };
|
|
1521
|
+
}
|
|
1522
|
+
|
|
1523
|
+
const txId = await snapshotManager.createSnapshot(client, useSFTP, [filePath]);
|
|
1524
|
+
|
|
1525
|
+
if (useSFTP) {
|
|
1526
|
+
const buffer = Buffer.from(content, 'utf8');
|
|
1527
|
+
await client.put(buffer, filePath);
|
|
1528
|
+
} else {
|
|
1529
|
+
const readable = Readable.from([content]);
|
|
1530
|
+
await client.uploadFrom(readable, filePath);
|
|
1531
|
+
}
|
|
1532
|
+
|
|
1533
|
+
return {
|
|
1534
|
+
content: [{ type: "text", text: `Successfully wrote content to ${filePath}\nTransaction ID: ${txId}` }]
|
|
1535
|
+
};
|
|
1536
|
+
}
|
|
1537
|
+
|
|
1538
|
+
case "ftp_stat": {
|
|
1539
|
+
const { path } = request.params.arguments;
|
|
1540
|
+
|
|
1541
|
+
if (useSFTP) {
|
|
1542
|
+
const stats = await client.stat(path);
|
|
1543
|
+
return {
|
|
1544
|
+
content: [{
|
|
1545
|
+
type: "text",
|
|
1546
|
+
text: JSON.stringify({
|
|
1547
|
+
size: stats.size,
|
|
1548
|
+
modified: stats.modifyTime,
|
|
1549
|
+
accessed: stats.accessTime,
|
|
1550
|
+
permissions: stats.mode,
|
|
1551
|
+
isDirectory: stats.isDirectory,
|
|
1552
|
+
isFile: stats.isFile
|
|
1553
|
+
}, null, 2)
|
|
1554
|
+
}]
|
|
1555
|
+
};
|
|
1556
|
+
} else {
|
|
1557
|
+
const dirPath = path.substring(0, path.lastIndexOf('/')) || '.';
|
|
1558
|
+
const fileName = path.substring(path.lastIndexOf('/') + 1);
|
|
1559
|
+
const files = await client.list(dirPath);
|
|
1560
|
+
const file = files.find(f => f.name === fileName);
|
|
1561
|
+
|
|
1562
|
+
if (!file) {
|
|
1563
|
+
throw new Error(`File not found: ${path}`);
|
|
1564
|
+
}
|
|
1565
|
+
|
|
1566
|
+
return {
|
|
1567
|
+
content: [{
|
|
1568
|
+
type: "text",
|
|
1569
|
+
text: JSON.stringify({
|
|
1570
|
+
size: file.size,
|
|
1571
|
+
modified: file.modifiedAt || file.rawModifiedAt,
|
|
1572
|
+
isDirectory: file.isDirectory,
|
|
1573
|
+
isFile: file.isFile
|
|
1574
|
+
}, null, 2)
|
|
1575
|
+
}]
|
|
1576
|
+
};
|
|
1577
|
+
}
|
|
1578
|
+
}
|
|
1579
|
+
|
|
1580
|
+
case "ftp_exists": {
|
|
1581
|
+
const { path } = request.params.arguments;
|
|
1582
|
+
let exists = false;
|
|
1583
|
+
|
|
1417
1584
|
try {
|
|
1418
1585
|
if (useSFTP) {
|
|
1419
|
-
await client.
|
|
1586
|
+
await client.stat(path);
|
|
1587
|
+
exists = true;
|
|
1420
1588
|
} else {
|
|
1421
|
-
|
|
1589
|
+
const dirPath = path.substring(0, path.lastIndexOf('/')) || '.';
|
|
1590
|
+
const fileName = path.substring(path.lastIndexOf('/') + 1);
|
|
1591
|
+
const files = await client.list(dirPath);
|
|
1592
|
+
exists = files.some(f => f.name === fileName);
|
|
1422
1593
|
}
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
results.failed.push({ path: file.remotePath, error: error.message });
|
|
1594
|
+
} catch (e) {
|
|
1595
|
+
exists = false;
|
|
1426
1596
|
}
|
|
1597
|
+
|
|
1598
|
+
return {
|
|
1599
|
+
content: [{ type: "text", text: exists ? "true" : "false" }]
|
|
1600
|
+
};
|
|
1427
1601
|
}
|
|
1428
|
-
|
|
1429
|
-
return {
|
|
1430
|
-
content: [{
|
|
1431
|
-
type: "text",
|
|
1432
|
-
text: `Downloaded: ${results.success.length}\nFailed: ${results.failed.length}\n${results.failed.length > 0 ? '\nErrors:\n' + results.failed.map(f => `${f.path}: ${f.error}`).join('\n') : ''}`
|
|
1433
|
-
}]
|
|
1434
|
-
};
|
|
1435
|
-
}
|
|
1436
1602
|
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1603
|
+
case "ftp_tree": {
|
|
1604
|
+
const { path = ".", maxDepth = 10 } = request.params.arguments || {};
|
|
1605
|
+
const cacheKey = `${path}:${maxDepth}`;
|
|
1606
|
+
let tree = getCached(poolKey, 'TREE', cacheKey);
|
|
1607
|
+
if (!tree) {
|
|
1608
|
+
tree = await getTreeRecursive(client, useSFTP, path, 0, maxDepth);
|
|
1609
|
+
setCached(poolKey, 'TREE', cacheKey, tree);
|
|
1610
|
+
}
|
|
1611
|
+
|
|
1612
|
+
const formatted = tree.map(item => {
|
|
1613
|
+
const indent = ' '.repeat((item.path.match(/\//g) || []).length);
|
|
1614
|
+
return `${indent}${item.isDirectory ? '📁' : '📄'} ${item.name} ${!item.isDirectory ? `(${item.size} bytes)` : ''}`;
|
|
1615
|
+
}).join('\n');
|
|
1448
1616
|
|
|
1449
|
-
case "ftp_disk_space": {
|
|
1450
|
-
const { path = "." } = request.params.arguments || {};
|
|
1451
|
-
|
|
1452
|
-
if (!useSFTP) {
|
|
1453
1617
|
return {
|
|
1454
|
-
content: [{ type: "text", text:
|
|
1618
|
+
content: [{ type: "text", text: formatted || "Empty directory" }]
|
|
1455
1619
|
};
|
|
1456
1620
|
}
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
const
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
}
|
|
1466
|
-
|
|
1621
|
+
|
|
1622
|
+
case "ftp_search": {
|
|
1623
|
+
const { pattern, contentPattern, extension, findLikelyConfigs, path: searchPath = ".", limit = 50, offset = 0 } = request.params.arguments;
|
|
1624
|
+
|
|
1625
|
+
if (!pattern && !contentPattern && !findLikelyConfigs) {
|
|
1626
|
+
return { content: [{ type: "text", text: "Error: Must provide pattern, contentPattern, or findLikelyConfigs" }], isError: true };
|
|
1627
|
+
}
|
|
1628
|
+
|
|
1629
|
+
const cacheKey = `${searchPath}:10`;
|
|
1630
|
+
let tree = getCached(poolKey, 'TREE', cacheKey);
|
|
1631
|
+
if (!tree) {
|
|
1632
|
+
tree = await getTreeRecursive(client, useSFTP, searchPath, 0, 10);
|
|
1633
|
+
setCached(poolKey, 'TREE', cacheKey, tree);
|
|
1634
|
+
}
|
|
1635
|
+
|
|
1636
|
+
let matches = tree;
|
|
1637
|
+
|
|
1638
|
+
if (findLikelyConfigs) {
|
|
1639
|
+
const configRegex = /config|env|auth|deploy|build|package\.json|composer\.json|dockerfile/i;
|
|
1640
|
+
matches = matches.filter(item => configRegex.test(item.name));
|
|
1641
|
+
}
|
|
1642
|
+
|
|
1643
|
+
if (pattern) {
|
|
1644
|
+
const regex = new RegExp(pattern.replace(/\*/g, '.*').replace(/\?/g, '.'), 'i');
|
|
1645
|
+
matches = matches.filter(item => regex.test(item.name));
|
|
1646
|
+
}
|
|
1647
|
+
|
|
1648
|
+
if (extension) {
|
|
1649
|
+
const ext = extension.startsWith('.') ? extension : `.${extension}`;
|
|
1650
|
+
matches = matches.filter(item => item.name.endsWith(ext));
|
|
1651
|
+
}
|
|
1652
|
+
|
|
1653
|
+
const total = matches.length;
|
|
1654
|
+
let sliced = matches.slice(offset, offset + limit);
|
|
1655
|
+
let formatted = "";
|
|
1656
|
+
|
|
1657
|
+
if (contentPattern) {
|
|
1658
|
+
const contentRegex = new RegExp(contentPattern, 'gi');
|
|
1659
|
+
const contentMatches = [];
|
|
1660
|
+
|
|
1661
|
+
for (const item of sliced) {
|
|
1662
|
+
if (item.isDirectory || item.size > 1024 * 1024) continue; // Skip dirs and files > 1MB
|
|
1663
|
+
|
|
1664
|
+
try {
|
|
1665
|
+
let content;
|
|
1666
|
+
if (useSFTP) {
|
|
1667
|
+
content = (await client.get(item.path)).toString('utf8');
|
|
1668
|
+
} else {
|
|
1669
|
+
const chunks = [];
|
|
1670
|
+
const stream = new Writable({ write(c, e, cb) { chunks.push(c); cb(); } });
|
|
1671
|
+
await client.downloadTo(stream, item.path);
|
|
1672
|
+
content = Buffer.concat(chunks).toString('utf8');
|
|
1673
|
+
}
|
|
1674
|
+
|
|
1675
|
+
const lines = content.split('\n');
|
|
1676
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1677
|
+
if (contentRegex.test(lines[i])) {
|
|
1678
|
+
const start = Math.max(0, i - 1);
|
|
1679
|
+
const end = Math.min(lines.length - 1, i + 1);
|
|
1680
|
+
const context = lines.slice(start, end + 1).map((l, idx) => `${start + idx + 1}: ${l}`).join('\n');
|
|
1681
|
+
contentMatches.push(`File: ${item.path}\n${context}\n---`);
|
|
1682
|
+
break; // Just show first match per file to save space
|
|
1683
|
+
}
|
|
1684
|
+
}
|
|
1685
|
+
} catch (e) {
|
|
1686
|
+
// Ignore read errors during search
|
|
1687
|
+
}
|
|
1688
|
+
}
|
|
1689
|
+
formatted = contentMatches.join('\n');
|
|
1690
|
+
} else {
|
|
1691
|
+
formatted = sliced.map(item =>
|
|
1692
|
+
`${item.path} (${item.isDirectory ? 'DIR' : item.size + ' bytes'})`
|
|
1693
|
+
).join('\n');
|
|
1694
|
+
}
|
|
1695
|
+
|
|
1696
|
+
const paginationInfo = `\n\nShowing ${offset + 1} to ${Math.min(offset + limit, total)} of ${total} matches.`;
|
|
1697
|
+
return {
|
|
1698
|
+
content: [{ type: "text", text: (formatted || "No matches found") + (total > limit ? paginationInfo : '') }]
|
|
1699
|
+
};
|
|
1700
|
+
}
|
|
1701
|
+
|
|
1702
|
+
case "ftp_analyze_workspace": {
|
|
1703
|
+
const path = request.params.arguments?.path || ".";
|
|
1704
|
+
let files = getCached(poolKey, 'LIST', path);
|
|
1705
|
+
if (!files) {
|
|
1706
|
+
files = useSFTP ? await client.list(path) : await client.list(path);
|
|
1707
|
+
setCached(poolKey, 'LIST', path, files);
|
|
1708
|
+
}
|
|
1709
|
+
|
|
1710
|
+
const fileNames = files.map(f => f.name);
|
|
1711
|
+
const importantFiles = ['package.json', 'composer.json', 'requirements.txt', 'pyproject.toml', 'Cargo.toml', 'go.mod', 'README.md'];
|
|
1712
|
+
const found = files.filter(f => importantFiles.includes(f.name));
|
|
1713
|
+
|
|
1714
|
+
let summary = `Workspace Analysis for: ${path}\n============================\n\n`;
|
|
1715
|
+
|
|
1716
|
+
// Framework Detection
|
|
1717
|
+
let framework = "Unknown";
|
|
1718
|
+
let recommendedIgnores = [];
|
|
1719
|
+
let dangerousFolders = [];
|
|
1720
|
+
|
|
1721
|
+
if (fileNames.includes('wp-config.php') || fileNames.includes('wp-content')) {
|
|
1722
|
+
framework = "WordPress";
|
|
1723
|
+
recommendedIgnores = ['wp-content/cache/**', 'wp-content/uploads/**', 'wp-config.php'];
|
|
1724
|
+
dangerousFolders = ['wp-content/uploads', 'wp-content/cache'];
|
|
1725
|
+
} else if (fileNames.includes('artisan') && fileNames.includes('composer.json')) {
|
|
1726
|
+
framework = "Laravel";
|
|
1727
|
+
recommendedIgnores = ['vendor/**', 'storage/framework/cache/**', 'storage/logs/**', '.env'];
|
|
1728
|
+
dangerousFolders = ['storage', 'bootstrap/cache'];
|
|
1729
|
+
} else if (fileNames.includes('next.config.js') || fileNames.includes('next.config.mjs')) {
|
|
1730
|
+
framework = "Next.js";
|
|
1731
|
+
recommendedIgnores = ['.next/**', 'node_modules/**', '.env*'];
|
|
1732
|
+
dangerousFolders = ['.next'];
|
|
1733
|
+
} else if (fileNames.includes('vite.config.js') || fileNames.includes('vite.config.ts')) {
|
|
1734
|
+
framework = "Vite/React/Vue";
|
|
1735
|
+
recommendedIgnores = ['dist/**', 'node_modules/**', '.env*'];
|
|
1736
|
+
dangerousFolders = ['dist'];
|
|
1737
|
+
} else if (fileNames.includes('package.json')) {
|
|
1738
|
+
framework = "Node.js (Generic)";
|
|
1739
|
+
recommendedIgnores = ['node_modules/**', '.env*'];
|
|
1740
|
+
dangerousFolders = ['node_modules'];
|
|
1741
|
+
} else if (fileNames.includes('composer.json')) {
|
|
1742
|
+
framework = "PHP (Composer)";
|
|
1743
|
+
recommendedIgnores = ['vendor/**', '.env*'];
|
|
1744
|
+
dangerousFolders = ['vendor'];
|
|
1745
|
+
}
|
|
1746
|
+
|
|
1747
|
+
summary += `Detected Framework: ${framework}\n`;
|
|
1748
|
+
if (recommendedIgnores.length > 0) {
|
|
1749
|
+
summary += `Recommended Ignores: ${recommendedIgnores.join(', ')}\n`;
|
|
1750
|
+
}
|
|
1751
|
+
if (dangerousFolders.length > 0) {
|
|
1752
|
+
summary += `Dangerous/Cache Folders (Avoid Overwriting): ${dangerousFolders.join(', ')}\n`;
|
|
1753
|
+
}
|
|
1754
|
+
summary += `\n----------------------------\n\n`;
|
|
1755
|
+
|
|
1756
|
+
if (found.length === 0) {
|
|
1757
|
+
summary += "No recognizable project configuration files found.";
|
|
1758
|
+
return { content: [{ type: "text", text: summary }] };
|
|
1759
|
+
}
|
|
1760
|
+
|
|
1761
|
+
for (const file of found) {
|
|
1762
|
+
const filePath = path === "." ? file.name : `${path}/${file.name}`;
|
|
1763
|
+
try {
|
|
1764
|
+
let content;
|
|
1765
|
+
if (useSFTP) {
|
|
1766
|
+
content = (await client.get(filePath)).toString('utf8');
|
|
1767
|
+
} else {
|
|
1768
|
+
const chunks = [];
|
|
1769
|
+
const stream = new Writable({ write(c, e, cb) { chunks.push(c); cb(); } });
|
|
1770
|
+
await client.downloadTo(stream, filePath);
|
|
1771
|
+
content = Buffer.concat(chunks).toString('utf8');
|
|
1772
|
+
}
|
|
1773
|
+
|
|
1774
|
+
if (file.name === 'package.json' || file.name === 'composer.json') {
|
|
1775
|
+
const parsed = JSON.parse(content);
|
|
1776
|
+
summary += `[${file.name}]\nName: ${parsed.name || 'Unknown'}\nVersion: ${parsed.version || 'Unknown'}\n`;
|
|
1777
|
+
if (parsed.dependencies || parsed.require) summary += `Dependencies: ${Object.keys(parsed.dependencies || parsed.require).slice(0, 10).join(', ')}...\n`;
|
|
1778
|
+
} else if (file.name === 'README.md') {
|
|
1779
|
+
summary += `[README.md (Preview)]\n${content.split('\n').filter(l => l.trim()).slice(0, 5).join('\n')}\n`;
|
|
1780
|
+
} else {
|
|
1781
|
+
summary += `[${file.name}]\n${content.split('\n').slice(0, 10).join('\n')}...\n`;
|
|
1782
|
+
}
|
|
1783
|
+
summary += '\n';
|
|
1784
|
+
} catch (e) {
|
|
1785
|
+
summary += `[${file.name}]\nCould not read file contents: ${e.message}\n\n`;
|
|
1786
|
+
}
|
|
1787
|
+
}
|
|
1788
|
+
|
|
1789
|
+
return {
|
|
1790
|
+
content: [{ type: "text", text: summary.trim() }]
|
|
1791
|
+
};
|
|
1792
|
+
}
|
|
1793
|
+
|
|
1794
|
+
case "ftp_copy": {
|
|
1795
|
+
const { sourcePath, destPath } = request.params.arguments;
|
|
1796
|
+
|
|
1797
|
+
if (!useSFTP) {
|
|
1798
|
+
return {
|
|
1799
|
+
content: [{ type: "text", text: "Error: ftp_copy is only supported for SFTP connections. For FTP, download and re-upload." }]
|
|
1800
|
+
};
|
|
1801
|
+
}
|
|
1802
|
+
|
|
1803
|
+
const buffer = await client.get(sourcePath);
|
|
1804
|
+
await client.put(buffer, destPath);
|
|
1805
|
+
|
|
1806
|
+
return {
|
|
1807
|
+
content: [{ type: "text", text: `Successfully copied ${sourcePath} to ${destPath}` }]
|
|
1808
|
+
};
|
|
1809
|
+
}
|
|
1810
|
+
|
|
1811
|
+
case "ftp_batch_upload": {
|
|
1812
|
+
const { files } = request.params.arguments;
|
|
1813
|
+
const results = { success: [], failed: [] };
|
|
1814
|
+
|
|
1815
|
+
for (const file of files) {
|
|
1816
|
+
try {
|
|
1817
|
+
if (useSFTP) {
|
|
1818
|
+
await client.put(file.localPath, file.remotePath);
|
|
1819
|
+
} else {
|
|
1820
|
+
await client.uploadFrom(file.localPath, file.remotePath);
|
|
1821
|
+
}
|
|
1822
|
+
results.success.push(file.remotePath);
|
|
1823
|
+
} catch (error) {
|
|
1824
|
+
results.failed.push({ path: file.remotePath, error: error.message });
|
|
1825
|
+
}
|
|
1826
|
+
}
|
|
1827
|
+
|
|
1467
1828
|
return {
|
|
1468
1829
|
content: [{
|
|
1469
1830
|
type: "text",
|
|
1470
|
-
text:
|
|
1471
|
-
total: diskSpace.blocks * diskSpace.bsize,
|
|
1472
|
-
free: diskSpace.bfree * diskSpace.bsize,
|
|
1473
|
-
available: diskSpace.bavail * diskSpace.bsize,
|
|
1474
|
-
used: (diskSpace.blocks - diskSpace.bfree) * diskSpace.bsize
|
|
1475
|
-
}, null, 2)
|
|
1831
|
+
text: `Uploaded: ${results.success.length}\nFailed: ${results.failed.length}\n${results.failed.length > 0 ? '\nErrors:\n' + results.failed.map(f => `${f.path}: ${f.error}`).join('\n') : ''}`
|
|
1476
1832
|
}]
|
|
1477
1833
|
};
|
|
1478
|
-
}
|
|
1834
|
+
}
|
|
1835
|
+
|
|
1836
|
+
case "ftp_batch_download": {
|
|
1837
|
+
const { files } = request.params.arguments;
|
|
1838
|
+
const results = { success: [], failed: [] };
|
|
1839
|
+
|
|
1840
|
+
for (const file of files) {
|
|
1841
|
+
try {
|
|
1842
|
+
if (useSFTP) {
|
|
1843
|
+
await client.get(file.remotePath, file.localPath);
|
|
1844
|
+
} else {
|
|
1845
|
+
await client.downloadTo(file.localPath, file.remotePath);
|
|
1846
|
+
}
|
|
1847
|
+
results.success.push(file.remotePath);
|
|
1848
|
+
} catch (error) {
|
|
1849
|
+
results.failed.push({ path: file.remotePath, error: error.message });
|
|
1850
|
+
}
|
|
1851
|
+
}
|
|
1852
|
+
|
|
1479
1853
|
return {
|
|
1480
|
-
content: [{
|
|
1854
|
+
content: [{
|
|
1855
|
+
type: "text",
|
|
1856
|
+
text: `Downloaded: ${results.success.length}\nFailed: ${results.failed.length}\n${results.failed.length > 0 ? '\nErrors:\n' + results.failed.map(f => `${f.path}: ${f.error}`).join('\n') : ''}`
|
|
1857
|
+
}]
|
|
1481
1858
|
};
|
|
1482
1859
|
}
|
|
1483
|
-
}
|
|
1484
1860
|
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1861
|
+
case "ftp_sync": {
|
|
1862
|
+
const { localPath, remotePath, direction = "upload", dryRun = false, useManifest = true } = request.params.arguments;
|
|
1863
|
+
const startTime = Date.now();
|
|
1864
|
+
const stats = await syncFiles(client, useSFTP, localPath, remotePath, direction, null, null, [], dryRun, useManifest);
|
|
1865
|
+
const duration = Date.now() - startTime;
|
|
1866
|
+
|
|
1867
|
+
if (!dryRun) {
|
|
1868
|
+
telemetry.syncDurations.push(duration);
|
|
1869
|
+
if (telemetry.syncDurations.length > 100) telemetry.syncDurations.shift(); // Keep last 100
|
|
1870
|
+
}
|
|
1871
|
+
|
|
1872
|
+
let resultText = `${dryRun ? '[DRY RUN] ' : ''}Sync complete in ${duration}ms:\nUploaded: ${stats.uploaded}\nDownloaded: ${stats.downloaded}\nSkipped: ${stats.skipped}\nIgnored: ${stats.ignored}\n${stats.errors.length > 0 ? '\nErrors:\n' + stats.errors.join('\n') : ''}`;
|
|
1873
|
+
|
|
1874
|
+
if (dryRun && stats.filesToChange.length > 0) {
|
|
1875
|
+
resultText += `\n\n${generateSemanticPreview(stats.filesToChange)}`;
|
|
1876
|
+
}
|
|
1877
|
+
|
|
1878
|
+
return {
|
|
1879
|
+
content: [{
|
|
1880
|
+
type: "text",
|
|
1881
|
+
text: resultText
|
|
1882
|
+
}]
|
|
1883
|
+
};
|
|
1492
1884
|
}
|
|
1493
|
-
|
|
1494
|
-
return {
|
|
1495
|
-
content: [{ type: "text", text: `Successfully uploaded ${localPath} to ${remotePath}` }]
|
|
1496
|
-
};
|
|
1497
|
-
}
|
|
1498
1885
|
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1886
|
+
case "ftp_disk_space": {
|
|
1887
|
+
const { path = "." } = request.params.arguments || {};
|
|
1888
|
+
|
|
1889
|
+
if (!useSFTP) {
|
|
1890
|
+
return {
|
|
1891
|
+
content: [{ type: "text", text: "Error: ftp_disk_space is only supported for SFTP connections" }]
|
|
1892
|
+
};
|
|
1893
|
+
}
|
|
1894
|
+
|
|
1895
|
+
try {
|
|
1896
|
+
const sftp = await client.sftp();
|
|
1897
|
+
const diskSpace = await new Promise((resolve, reject) => {
|
|
1898
|
+
sftp.ext_openssh_statvfs(path, (err, stats) => {
|
|
1899
|
+
if (err) reject(err);
|
|
1900
|
+
else resolve(stats);
|
|
1901
|
+
});
|
|
1902
|
+
});
|
|
1903
|
+
|
|
1904
|
+
return {
|
|
1905
|
+
content: [{
|
|
1906
|
+
type: "text",
|
|
1907
|
+
text: JSON.stringify({
|
|
1908
|
+
total: diskSpace.blocks * diskSpace.bsize,
|
|
1909
|
+
free: diskSpace.bfree * diskSpace.bsize,
|
|
1910
|
+
available: diskSpace.bavail * diskSpace.bsize,
|
|
1911
|
+
used: (diskSpace.blocks - diskSpace.bfree) * diskSpace.bsize
|
|
1912
|
+
}, null, 2)
|
|
1913
|
+
}]
|
|
1914
|
+
};
|
|
1915
|
+
} catch (error) {
|
|
1916
|
+
return {
|
|
1917
|
+
content: [{ type: "text", text: `Disk space info not available: ${error.message}` }]
|
|
1918
|
+
};
|
|
1919
|
+
}
|
|
1506
1920
|
}
|
|
1507
|
-
|
|
1508
|
-
return {
|
|
1509
|
-
content: [{ type: "text", text: `Successfully downloaded ${remotePath} to ${localPath}` }]
|
|
1510
|
-
};
|
|
1511
|
-
}
|
|
1512
1921
|
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1922
|
+
case "ftp_upload": {
|
|
1923
|
+
const { localPath, remotePath } = request.params.arguments;
|
|
1924
|
+
|
|
1925
|
+
if (isSecretFile(localPath)) {
|
|
1926
|
+
return {
|
|
1927
|
+
content: [{ type: "text", text: `Security Warning: Blocked upload of likely secret file: ${localPath}` }],
|
|
1928
|
+
isError: true
|
|
1929
|
+
};
|
|
1930
|
+
}
|
|
1931
|
+
|
|
1932
|
+
try {
|
|
1933
|
+
const stat = await fs.stat(localPath);
|
|
1934
|
+
policyEngine.validateOperation('overwrite', { path: remotePath, size: stat.size });
|
|
1935
|
+
} catch (e) {
|
|
1936
|
+
return { content: [{ type: "text", text: e.message }], isError: true };
|
|
1937
|
+
}
|
|
1938
|
+
|
|
1939
|
+
const txId = await snapshotManager.createSnapshot(client, useSFTP, [remotePath]);
|
|
1940
|
+
|
|
1941
|
+
if (useSFTP) {
|
|
1942
|
+
await client.put(localPath, remotePath);
|
|
1943
|
+
} else {
|
|
1944
|
+
await client.uploadFrom(localPath, remotePath);
|
|
1945
|
+
}
|
|
1946
|
+
|
|
1947
|
+
return {
|
|
1948
|
+
content: [{ type: "text", text: `Successfully uploaded ${localPath} to ${remotePath}\nTransaction ID: ${txId}` }]
|
|
1949
|
+
};
|
|
1520
1950
|
}
|
|
1521
|
-
|
|
1522
|
-
return {
|
|
1523
|
-
content: [{ type: "text", text: `Successfully deleted ${path}` }]
|
|
1524
|
-
};
|
|
1525
|
-
}
|
|
1526
1951
|
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1952
|
+
case "ftp_download": {
|
|
1953
|
+
const { remotePath, localPath } = request.params.arguments;
|
|
1954
|
+
|
|
1955
|
+
if (useSFTP) {
|
|
1956
|
+
await client.get(remotePath, localPath);
|
|
1957
|
+
} else {
|
|
1958
|
+
await client.downloadTo(localPath, remotePath);
|
|
1959
|
+
}
|
|
1960
|
+
|
|
1961
|
+
return {
|
|
1962
|
+
content: [{ type: "text", text: `Successfully downloaded ${remotePath} to ${localPath}` }]
|
|
1963
|
+
};
|
|
1534
1964
|
}
|
|
1535
|
-
|
|
1536
|
-
return {
|
|
1537
|
-
content: [{ type: "text", text: `Successfully created directory ${path}` }]
|
|
1538
|
-
};
|
|
1539
|
-
}
|
|
1540
1965
|
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1966
|
+
case "ftp_delete": {
|
|
1967
|
+
const { path: filePath } = request.params.arguments;
|
|
1968
|
+
|
|
1969
|
+
try {
|
|
1970
|
+
policyEngine.validateOperation('delete', { path: filePath });
|
|
1971
|
+
} catch (e) {
|
|
1972
|
+
return { content: [{ type: "text", text: e.message }], isError: true };
|
|
1973
|
+
}
|
|
1974
|
+
|
|
1975
|
+
const txId = await snapshotManager.createSnapshot(client, useSFTP, [filePath]);
|
|
1976
|
+
|
|
1977
|
+
if (useSFTP) {
|
|
1978
|
+
await client.delete(filePath);
|
|
1549
1979
|
} else {
|
|
1550
|
-
await client.remove(
|
|
1980
|
+
await client.remove(filePath);
|
|
1551
1981
|
}
|
|
1982
|
+
|
|
1983
|
+
return {
|
|
1984
|
+
content: [{ type: "text", text: `Successfully deleted ${filePath}\nTransaction ID: ${txId}` }]
|
|
1985
|
+
};
|
|
1552
1986
|
}
|
|
1553
|
-
|
|
1554
|
-
return {
|
|
1555
|
-
content: [{ type: "text", text: `Successfully removed directory ${path}` }]
|
|
1556
|
-
};
|
|
1557
|
-
}
|
|
1558
1987
|
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1988
|
+
case "ftp_mkdir": {
|
|
1989
|
+
const { path } = request.params.arguments;
|
|
1990
|
+
|
|
1991
|
+
if (useSFTP) {
|
|
1992
|
+
await client.mkdir(path, true);
|
|
1993
|
+
} else {
|
|
1994
|
+
await client.ensureDir(path);
|
|
1995
|
+
}
|
|
1996
|
+
|
|
1563
1997
|
return {
|
|
1564
|
-
content: [{ type: "text", text:
|
|
1998
|
+
content: [{ type: "text", text: `Successfully created directory ${path}` }]
|
|
1565
1999
|
};
|
|
1566
2000
|
}
|
|
1567
|
-
|
|
1568
|
-
await client.chmod(path, mode);
|
|
1569
|
-
|
|
1570
|
-
return {
|
|
1571
|
-
content: [{ type: "text", text: `Successfully changed permissions of ${path} to ${mode}` }]
|
|
1572
|
-
};
|
|
1573
|
-
}
|
|
1574
2001
|
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
2002
|
+
case "ftp_rmdir": {
|
|
2003
|
+
const { path, recursive } = request.params.arguments;
|
|
2004
|
+
|
|
2005
|
+
if (useSFTP) {
|
|
2006
|
+
await client.rmdir(path, recursive);
|
|
2007
|
+
} else {
|
|
2008
|
+
if (recursive) {
|
|
2009
|
+
await client.removeDir(path);
|
|
2010
|
+
} else {
|
|
2011
|
+
await client.remove(path);
|
|
2012
|
+
}
|
|
2013
|
+
}
|
|
2014
|
+
|
|
2015
|
+
return {
|
|
2016
|
+
content: [{ type: "text", text: `Successfully removed directory ${path}` }]
|
|
2017
|
+
};
|
|
1582
2018
|
}
|
|
1583
|
-
|
|
1584
|
-
return {
|
|
1585
|
-
content: [{ type: "text", text: `Successfully renamed ${oldPath} to ${newPath}` }]
|
|
1586
|
-
};
|
|
1587
|
-
}
|
|
1588
2019
|
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
2020
|
+
case "ftp_chmod": {
|
|
2021
|
+
const { path, mode } = request.params.arguments;
|
|
2022
|
+
|
|
2023
|
+
if (!useSFTP) {
|
|
2024
|
+
return {
|
|
2025
|
+
content: [{ type: "text", text: "Error: chmod is only supported for SFTP connections" }]
|
|
2026
|
+
};
|
|
2027
|
+
}
|
|
2028
|
+
|
|
2029
|
+
await client.chmod(path, mode);
|
|
2030
|
+
|
|
2031
|
+
return {
|
|
2032
|
+
content: [{ type: "text", text: `Successfully changed permissions of ${path} to ${mode}` }]
|
|
2033
|
+
};
|
|
2034
|
+
}
|
|
2035
|
+
|
|
2036
|
+
case "ftp_rename": {
|
|
2037
|
+
const { oldPath, newPath } = request.params.arguments;
|
|
2038
|
+
|
|
2039
|
+
try {
|
|
2040
|
+
policyEngine.validateOperation('delete', { path: oldPath }); // Renaming is effectively deleting the old path
|
|
2041
|
+
policyEngine.validateOperation('overwrite', { path: newPath });
|
|
2042
|
+
} catch (e) {
|
|
2043
|
+
return { content: [{ type: "text", text: e.message }], isError: true };
|
|
2044
|
+
}
|
|
2045
|
+
|
|
2046
|
+
const txId = await snapshotManager.createSnapshot(client, useSFTP, [oldPath, newPath]);
|
|
2047
|
+
|
|
2048
|
+
if (useSFTP) {
|
|
2049
|
+
await client.rename(oldPath, newPath);
|
|
2050
|
+
} else {
|
|
2051
|
+
await client.rename(oldPath, newPath);
|
|
2052
|
+
}
|
|
2053
|
+
|
|
2054
|
+
return {
|
|
2055
|
+
content: [{ type: "text", text: `Successfully renamed ${oldPath} to ${newPath}\nTransaction ID: ${txId}` }]
|
|
2056
|
+
};
|
|
2057
|
+
}
|
|
2058
|
+
|
|
2059
|
+
case "ftp_rollback": {
|
|
2060
|
+
const { transactionId } = request.params.arguments;
|
|
2061
|
+
try {
|
|
2062
|
+
const results = await snapshotManager.rollback(client, useSFTP, transactionId);
|
|
2063
|
+
return {
|
|
2064
|
+
content: [{
|
|
2065
|
+
type: "text",
|
|
2066
|
+
text: `Rollback complete for ${transactionId}:\nRestored: ${results.restored.length}\nDeleted: ${results.deleted.length}\nFailed: ${results.failed.length}\n${results.failed.length > 0 ? '\nErrors:\n' + results.failed.map(f => `${f.path}: ${f.error}`).join('\n') : ''}`
|
|
2067
|
+
}]
|
|
2068
|
+
};
|
|
2069
|
+
} catch (error) {
|
|
2070
|
+
return {
|
|
2071
|
+
content: [{ type: "text", text: `Rollback failed: ${error.message}` }],
|
|
2072
|
+
isError: true
|
|
2073
|
+
};
|
|
2074
|
+
}
|
|
2075
|
+
}
|
|
2076
|
+
|
|
2077
|
+
case "ftp_list_transactions": {
|
|
2078
|
+
try {
|
|
2079
|
+
const transactions = await snapshotManager.listTransactions();
|
|
2080
|
+
if (transactions.length === 0) {
|
|
2081
|
+
return { content: [{ type: "text", text: "No transactions found." }] };
|
|
2082
|
+
}
|
|
2083
|
+
|
|
2084
|
+
const formatted = transactions.map(tx => {
|
|
2085
|
+
return `ID: ${tx.transactionId}\nTime: ${tx.timestamp}\nFiles: ${tx.files.map(f => f.remotePath).join(', ')}`;
|
|
2086
|
+
}).join('\n\n');
|
|
2087
|
+
|
|
2088
|
+
return { content: [{ type: "text", text: `Available Transactions:\n\n${formatted}` }] };
|
|
2089
|
+
} catch (error) {
|
|
2090
|
+
return {
|
|
2091
|
+
content: [{ type: "text", text: `Failed to list transactions: ${error.message}` }],
|
|
2092
|
+
isError: true
|
|
2093
|
+
};
|
|
2094
|
+
}
|
|
2095
|
+
}
|
|
2096
|
+
|
|
2097
|
+
case "ftp_telemetry": {
|
|
2098
|
+
const avgSyncDuration = telemetry.syncDurations.length > 0
|
|
2099
|
+
? telemetry.syncDurations.reduce((a, b) => a + b, 0) / telemetry.syncDurations.length
|
|
2100
|
+
: 0;
|
|
2101
|
+
|
|
2102
|
+
return {
|
|
2103
|
+
content: [{
|
|
2104
|
+
type: "text",
|
|
2105
|
+
text: `Connection Health Telemetry:\n===========================\nActive Connections: ${telemetry.activeConnections}\nTotal Reconnects: ${telemetry.reconnects}\nCache Hits: ${telemetry.cacheHits}\nCache Misses: ${telemetry.cacheMisses}\nCache Hit Rate: ${telemetry.cacheHits + telemetry.cacheMisses > 0 ? Math.round((telemetry.cacheHits / (telemetry.cacheHits + telemetry.cacheMisses)) * 100) : 0}%\nAverage Sync Duration: ${Math.round(avgSyncDuration)}ms (last ${telemetry.syncDurations.length} syncs)`
|
|
2106
|
+
}]
|
|
2107
|
+
};
|
|
2108
|
+
}
|
|
2109
|
+
|
|
2110
|
+
case "ftp_probe_capabilities": {
|
|
2111
|
+
const { testPath = "." } = request.params.arguments || {};
|
|
2112
|
+
const capabilities = {
|
|
2113
|
+
protocol: useSFTP ? 'SFTP' : 'FTP',
|
|
2114
|
+
chmod: false,
|
|
2115
|
+
symlinks: false,
|
|
2116
|
+
diskSpace: false,
|
|
2117
|
+
atomicRename: false,
|
|
2118
|
+
checksums: false
|
|
2119
|
+
};
|
|
2120
|
+
|
|
2121
|
+
const testFile = `${testPath}/.ftp-mcp-probe-${Date.now()}.txt`;
|
|
2122
|
+
const testRename = `${testPath}/.ftp-mcp-probe-renamed-${Date.now()}.txt`;
|
|
2123
|
+
|
|
2124
|
+
try {
|
|
2125
|
+
// 1. Test basic write
|
|
2126
|
+
if (useSFTP) {
|
|
2127
|
+
await client.put(Buffer.from('test', 'utf8'), testFile);
|
|
2128
|
+
} else {
|
|
2129
|
+
await client.uploadFrom(Readable.from(['test']), testFile);
|
|
2130
|
+
}
|
|
2131
|
+
|
|
2132
|
+
// 2. Test chmod (SFTP only usually)
|
|
2133
|
+
if (useSFTP) {
|
|
2134
|
+
try {
|
|
2135
|
+
await client.chmod(testFile, '644');
|
|
2136
|
+
capabilities.chmod = true;
|
|
2137
|
+
} catch (e) { }
|
|
2138
|
+
}
|
|
2139
|
+
|
|
2140
|
+
// 3. Test atomic rename
|
|
2141
|
+
try {
|
|
2142
|
+
await client.rename(testFile, testRename);
|
|
2143
|
+
capabilities.atomicRename = true;
|
|
2144
|
+
} catch (e) { }
|
|
2145
|
+
|
|
2146
|
+
// 4. Test disk space (SFTP only usually)
|
|
2147
|
+
if (useSFTP) {
|
|
2148
|
+
try {
|
|
2149
|
+
const sftp = await client.sftp();
|
|
2150
|
+
await new Promise((resolve, reject) => {
|
|
2151
|
+
sftp.ext_openssh_statvfs(testPath, (err, stats) => {
|
|
2152
|
+
if (err) reject(err);
|
|
2153
|
+
else resolve(stats);
|
|
2154
|
+
});
|
|
2155
|
+
});
|
|
2156
|
+
capabilities.diskSpace = true;
|
|
2157
|
+
} catch (e) { }
|
|
2158
|
+
}
|
|
2159
|
+
|
|
2160
|
+
// Cleanup
|
|
2161
|
+
try {
|
|
2162
|
+
if (capabilities.atomicRename) {
|
|
2163
|
+
if (useSFTP) await client.delete(testRename);
|
|
2164
|
+
else await client.remove(testRename);
|
|
2165
|
+
} else {
|
|
2166
|
+
if (useSFTP) await client.delete(testFile);
|
|
2167
|
+
else await client.remove(testFile);
|
|
2168
|
+
}
|
|
2169
|
+
} catch (e) { }
|
|
2170
|
+
|
|
2171
|
+
} catch (error) {
|
|
2172
|
+
return {
|
|
2173
|
+
content: [{ type: "text", text: `Probing failed during basic operations: ${error.message}` }],
|
|
2174
|
+
isError: true
|
|
2175
|
+
};
|
|
2176
|
+
}
|
|
2177
|
+
|
|
2178
|
+
return {
|
|
2179
|
+
content: [{
|
|
2180
|
+
type: "text",
|
|
2181
|
+
text: `Server Capabilities:\n${JSON.stringify(capabilities, null, 2)}`
|
|
2182
|
+
}]
|
|
2183
|
+
};
|
|
2184
|
+
}
|
|
2185
|
+
|
|
2186
|
+
default:
|
|
2187
|
+
return {
|
|
2188
|
+
content: [{ type: "text", text: `Unknown tool: ${request.params.name}` }],
|
|
2189
|
+
isError: true
|
|
2190
|
+
};
|
|
1594
2191
|
}
|
|
1595
|
-
})
|
|
1596
|
-
|
|
2192
|
+
});
|
|
2193
|
+
|
|
1597
2194
|
await auditLog(cmdName, request.params.arguments, response.isError ? 'failed' : 'success', currentProfile, response.isError ? response.content[0].text : null);
|
|
1598
2195
|
return response;
|
|
1599
2196
|
} catch (error) {
|
|
2197
|
+
console.error(`[Fatal Tool Error] ${request.params.name}:`, error);
|
|
1600
2198
|
return {
|
|
1601
2199
|
content: [{ type: "text", text: `Error: ${error.message}` }],
|
|
1602
2200
|
isError: true
|
|
1603
2201
|
};
|
|
1604
2202
|
} finally {
|
|
1605
|
-
if (
|
|
2203
|
+
if (currentConfig) {
|
|
1606
2204
|
releaseClient(currentConfig);
|
|
1607
2205
|
}
|
|
1608
2206
|
}
|