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 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 Flowdex MCP Initialization Wizard');
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
- const destConfig = path.join(process.cwd(), ".ftpconfig");
72
- await fs.writeFile(destConfig, JSON.stringify(config, null, 2), 'utf8');
73
-
74
- note(`✅ Successfully generated config file at:\n${destConfig}`, 'Success');
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 = path.join(process.cwd(), '.ftpconfig');
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) return entry.data;
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.client;
334
+ return existing;
289
335
  }
290
336
 
291
- const useSFTP = isSFTP(config.host);
292
- const client = useSFTP ? await connectSFTP(config) : await connectFTP(config);
293
-
294
- client._isSFTP = useSFTP;
295
- const onClose = () => handleClientClose(poolKey);
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
- connectionPool.set(poolKey, { client, closed: false });
307
- return client;
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
- try {
422
- const remoteStat = useSFTP
423
- ? await client.stat(remoteFilePath)
424
- : (await client.list(remotePath)).find(f => f.name === file.name);
425
-
426
- if (remoteStat) {
427
- const remoteTime = remoteStat.modifyTime || remoteStat.modifiedAt || new Date(remoteStat.rawModifiedAt);
428
- if (localStat.mtime <= remoteTime) {
429
- shouldUpload = false;
430
- stats.skipped++;
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
- if (useSFTP) {
440
- await client.put(localFilePath, remoteFilePath);
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
- await client.uploadFrom(localFilePath, remoteFilePath);
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.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: "Find files by name pattern on FTP/SFTP server",
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: "Search pattern (supports wildcards like *.js)"
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
- client = await getClient(currentConfig);
1073
- const poolKey = getPoolKey(currentConfig);
1074
- const cmdName = request.params.name;
1075
- 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);
1076
-
1077
- if (isDestructive) {
1078
- if (currentConfig.readOnly) {
1079
- const errorResp = {
1080
- content: [{ type: "text", text: `Error: Profile '${currentProfile}' is configured in readOnly mode. Destructive actions are disabled.` }],
1081
- isError: true
1082
- };
1083
- await auditLog(cmdName, request.params.arguments, 'failed', currentProfile, 'readOnly mode violation');
1084
- return errorResp;
1085
- }
1086
- invalidatePoolCache(poolKey);
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
- const response = await (async () => {
1090
- switch (cmdName) {
1091
- case "ftp_list": {
1092
- const path = request.params.arguments?.path || ".";
1093
- const limit = request.params.arguments?.limit || 100;
1094
- const offset = request.params.arguments?.offset || 0;
1095
-
1096
- let files = getCached(poolKey, 'LIST', path);
1097
- if (!files) {
1098
- files = useSFTP ? await client.list(path) : await client.list(path);
1099
- files.sort((a,b) => a.name.localeCompare(b.name));
1100
- setCached(poolKey, 'LIST', path, files);
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
- case "ftp_get_contents": {
1119
- const { path, startLine, endLine } = request.params.arguments;
1120
- let content;
1121
-
1122
- if (useSFTP) {
1123
- const buffer = await client.get(path);
1124
- content = buffer.toString('utf8');
1125
- } else {
1126
- const chunks = [];
1127
- const stream = new Writable({
1128
- write(chunk, encoding, callback) {
1129
- chunks.push(chunk);
1130
- callback();
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
- case "ftp_patch_file": {
1154
- const { path, patch } = request.params.arguments;
1155
-
1156
- let content;
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({ write(chunk, encoding, callback) { chunks.push(chunk); callback(); } });
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
- case "ftp_put_contents": {
1196
- const { path, content } = request.params.arguments;
1197
-
1198
- if (useSFTP) {
1199
- const buffer = Buffer.from(content, 'utf8');
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
- case "ftp_stat": {
1212
- const { path } = request.params.arguments;
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
- case "ftp_tree": {
1277
- const { path = ".", maxDepth = 10 } = request.params.arguments || {};
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
- case "ftp_search": {
1296
- const { pattern, path = ".", limit = 50, offset = 0 } = request.params.arguments;
1297
- const cacheKey = `${path}:10`;
1298
- let tree = getCached(poolKey, 'TREE', cacheKey);
1299
- if (!tree) {
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
- case "ftp_analyze_workspace": {
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
- content = (await client.get(filePath)).toString('utf8');
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(c, e, cb) { chunks.push(c); cb(); } });
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
- summary += `[${file.name}]\nCould not read file contents: ${e.message}\n\n`;
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
- case "ftp_copy": {
1371
- const { sourcePath, destPath } = request.params.arguments;
1372
-
1373
- if (!useSFTP) {
1374
- return {
1375
- content: [{ type: "text", text: "Error: ftp_copy is only supported for SFTP connections. For FTP, download and re-upload." }]
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
- case "ftp_batch_upload": {
1388
- const { files } = request.params.arguments;
1389
- const results = { success: [], failed: [] };
1390
-
1391
- for (const file of files) {
1392
- try {
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
- await client.put(file.localPath, file.remotePath);
1493
+ const buffer = Buffer.from(content, 'utf8');
1494
+ await client.put(buffer, backupPath);
1395
1495
  } else {
1396
- await client.uploadFrom(file.localPath, file.remotePath);
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
- case "ftp_batch_download": {
1413
- const { files } = request.params.arguments;
1414
- const results = { success: [], failed: [] };
1415
-
1416
- for (const file of files) {
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.get(file.remotePath, file.localPath);
1586
+ await client.stat(path);
1587
+ exists = true;
1420
1588
  } else {
1421
- await client.downloadTo(file.localPath, file.remotePath);
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
- results.success.push(file.remotePath);
1424
- } catch (error) {
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
- case "ftp_sync": {
1438
- const { localPath, remotePath, direction = "upload", dryRun = false } = request.params.arguments;
1439
- const stats = await syncFiles(client, useSFTP, localPath, remotePath, direction, null, null, [], dryRun);
1440
-
1441
- return {
1442
- content: [{
1443
- type: "text",
1444
- text: `${dryRun ? '[DRY RUN] ' : ''}Sync complete:\nUploaded: ${stats.uploaded}\nDownloaded: ${stats.downloaded}\nSkipped: ${stats.skipped}\nIgnored: ${stats.ignored}\n${stats.errors.length > 0 ? '\nErrors:\n' + stats.errors.join('\n') : ''}`
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: "Error: ftp_disk_space is only supported for SFTP connections" }]
1618
+ content: [{ type: "text", text: formatted || "Empty directory" }]
1455
1619
  };
1456
1620
  }
1457
-
1458
- try {
1459
- const sftp = await client.sftp();
1460
- const diskSpace = await new Promise((resolve, reject) => {
1461
- sftp.ext_openssh_statvfs(path, (err, stats) => {
1462
- if (err) reject(err);
1463
- else resolve(stats);
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: JSON.stringify({
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
- } catch (error) {
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: [{ type: "text", text: `Disk space info not available: ${error.message}` }]
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
- case "ftp_upload": {
1486
- const { localPath, remotePath } = request.params.arguments;
1487
-
1488
- if (useSFTP) {
1489
- await client.put(localPath, remotePath);
1490
- } else {
1491
- await client.uploadFrom(localPath, remotePath);
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
- case "ftp_download": {
1500
- const { remotePath, localPath } = request.params.arguments;
1501
-
1502
- if (useSFTP) {
1503
- await client.get(remotePath, localPath);
1504
- } else {
1505
- await client.downloadTo(localPath, remotePath);
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
- case "ftp_delete": {
1514
- const { path } = request.params.arguments;
1515
-
1516
- if (useSFTP) {
1517
- await client.delete(path);
1518
- } else {
1519
- await client.remove(path);
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
- case "ftp_mkdir": {
1528
- const { path } = request.params.arguments;
1529
-
1530
- if (useSFTP) {
1531
- await client.mkdir(path, true);
1532
- } else {
1533
- await client.ensureDir(path);
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
- case "ftp_rmdir": {
1542
- const { path, recursive } = request.params.arguments;
1543
-
1544
- if (useSFTP) {
1545
- await client.rmdir(path, recursive);
1546
- } else {
1547
- if (recursive) {
1548
- await client.removeDir(path);
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(path);
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
- case "ftp_chmod": {
1560
- const { path, mode } = request.params.arguments;
1561
-
1562
- if (!useSFTP) {
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: "Error: chmod is only supported for SFTP connections" }]
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
- case "ftp_rename": {
1576
- const { oldPath, newPath } = request.params.arguments;
1577
-
1578
- if (useSFTP) {
1579
- await client.rename(oldPath, newPath);
1580
- } else {
1581
- await client.rename(oldPath, newPath);
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
- default:
1590
- return {
1591
- content: [{ type: "text", text: `Unknown tool: ${request.params.name}` }],
1592
- isError: true
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 (client) {
2203
+ if (currentConfig) {
1606
2204
  releaseClient(currentConfig);
1607
2205
  }
1608
2206
  }