ftp-mcp 1.5.1 → 1.5.3

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.
Files changed (2) hide show
  1. package/index.js +140 -42
  2. package/package.json +1 -1
package/index.js CHANGED
@@ -313,6 +313,45 @@ function assertSafeRemotePath(remotePath) {
313
313
  }
314
314
  }
315
315
 
316
+ /**
317
+ * Ensures that a specific remote directory exists.
318
+ * For standard FTP, this utility also handles the crucial reset of the
319
+ * Current Working Directory (CWD) to prevent implicit state leakage.
320
+ */
321
+ async function safeMkdir(client, useSFTP, remotePath) {
322
+ if (!remotePath || remotePath === '.' || remotePath === '/') return;
323
+
324
+ if (useSFTP) {
325
+ // SFTP mkdir with recursive: true
326
+ await client.mkdir(remotePath, true).catch(() => {});
327
+ } else {
328
+ // FTP ensureDir switches CWD; we MUST reset it.
329
+ try {
330
+ await client.ensureDir(remotePath);
331
+ if (client._initialPwd) {
332
+ await client.cd(client._initialPwd).catch(() => {});
333
+ }
334
+ } catch (e) {
335
+ // Some servers might error if directory already exists or permissions are weird
336
+ }
337
+ }
338
+ }
339
+
340
+ /**
341
+ * Ensures that the parent directory structure of a remote file path exists.
342
+ */
343
+ async function ensureRemoteDir(client, useSFTP, remotePath) {
344
+ // remotePath could be a filename or a path like "dir/subdir/file.js"
345
+ // We extract the directory portion using forward-slash splitting.
346
+ const parts = remotePath.split('/');
347
+ if (parts.length <= 1) return; // Root or simple filename, nothing to ensure
348
+
349
+ const dirName = parts.slice(0, -1).join('/');
350
+ if (!dirName || dirName === '.' || dirName === '/') return;
351
+
352
+ await safeMkdir(client, useSFTP, dirName);
353
+ }
354
+
316
355
  function shouldIgnore(filePath, ignorePatterns, basePath) {
317
356
  const relativePath = path.relative(basePath, filePath).replace(/\\/g, '/');
318
357
 
@@ -333,6 +372,9 @@ const ProfileConfigSchema = z.object({
333
372
  password: z.string().optional(),
334
373
  port: z.union([z.string(), z.number()]).optional(),
335
374
  secure: z.union([z.boolean(), z.literal("implicit")]).optional(),
375
+ secureOptions: z.record(z.any()).optional(),
376
+ verbose: z.boolean().optional(),
377
+ timeout: z.number().optional(),
336
378
  readOnly: z.boolean().optional(),
337
379
  privateKey: z.string().optional(),
338
380
  passphrase: z.string().optional(),
@@ -396,8 +438,9 @@ async function loadFTPConfig(profileName = null, forceEnv = false) {
396
438
 
397
439
  function getPort(host, configPort) {
398
440
  if (configPort) return parseInt(configPort, 10);
399
- // LOW-4: Use strict prefix check, not includes() which matches e.g. "mysftp-server.com"
441
+ // LOW-4: Use strict prefix check
400
442
  if (host && host.startsWith('sftp://')) return 22;
443
+ if (host && host.startsWith('ftp://')) return 21;
401
444
  return 21;
402
445
  }
403
446
 
@@ -409,13 +452,32 @@ function isSFTP(host) {
409
452
  async function connectFTP(config) {
410
453
  const client = new FTPClient();
411
454
  client.ftp.verbose = false;
455
+
456
+ if (config.verbose) {
457
+ client.ftp.log = (msg) => {
458
+ server.sendLoggingMessage({
459
+ level: "debug",
460
+ data: { message: `[FTP-RAW] ${msg}` }
461
+ });
462
+ };
463
+ }
464
+
412
465
  await client.access({
413
- host: config.host,
466
+ host: config.host.replace('ftp://', '').replace('sftp://', ''), // Strip protocol for basic-ftp
414
467
  user: config.user,
415
468
  password: config.password,
416
469
  port: getPort(config.host, config.port),
417
- secure: config.secure || false
470
+ secure: config.secure || false,
471
+ secureOptions: config.secureOptions || { rejectUnauthorized: false }, // Default to false for easy dev if not specified
472
+ timeout: config.timeout || 30000 // 30s connection timeout
418
473
  });
474
+
475
+ try {
476
+ client._initialPwd = await client.pwd();
477
+ } catch (e) {
478
+ client._initialPwd = '/'; // fallback if pwd fails
479
+ }
480
+
419
481
  return client;
420
482
  }
421
483
 
@@ -512,7 +574,7 @@ async function getClient(config) {
512
574
  telemetry.activeConnections++;
513
575
 
514
576
  client._isSFTP = useSFTP;
515
-
577
+
516
578
  // Silence MaxListenersExceededWarning during high-activity syncs/sessions
517
579
  if (typeof client.setMaxListeners === 'function') {
518
580
  client.setMaxListeners(100);
@@ -527,7 +589,7 @@ async function getClient(config) {
527
589
  async execute(task) {
528
590
  // Use a simple promise chain to serialize operations on this client
529
591
  const result = this.promiseQueue.then(() => task(this.client));
530
- this.promiseQueue = result.catch(() => {}); // Continue queue even on error
592
+ this.promiseQueue = result.catch(() => { }); // Continue queue even on error
531
593
  return result;
532
594
  }
533
595
  };
@@ -657,8 +719,34 @@ async function syncFiles(client, useSFTP, localPath, remotePath, direction, igno
657
719
  ignorePatterns = await loadIgnorePatterns(localPath);
658
720
  basePath = localPath;
659
721
  _isTopLevel = true;
722
+
723
+ // --- ANTI-RECURSION GUARD ---
724
+ // If we're syncing the current directory (.), and the remote target is a local folder (e.g. 'FlowdexMCP_LiveTest'),
725
+ // we MUST ignore the remote target folder locally so we don't upload it into itself forever.
726
+ // This caused more issues than I'm willing to admit.
727
+ const resolvedLocal = path.resolve(localPath);
728
+ // Rough check: if remotePath is just a folder name (no slashes) or a relative path, ignore it
729
+ if (remotePath && typeof remotePath === 'string' && !remotePath.startsWith('/')) {
730
+ const firstFolder = remotePath.split('/')[0];
731
+ if (firstFolder && firstFolder !== '.') {
732
+ ignorePatterns.push(firstFolder);
733
+ ignorePatterns.push(`${firstFolder}/**`);
734
+
735
+ // Re-instantiate the Ignore instance since we appended pattern strings
736
+ if (ignorePatterns._ig) delete ignorePatterns._ig;
737
+
738
+ server.notification({
739
+ method: "notifications/message",
740
+ params: {
741
+ level: "info",
742
+ data: { message: `[SYNC GUARD] Excluded target '${firstFolder}' from local sync to prevent infinite recursion.` }
743
+ }
744
+ });
745
+ }
746
+ }
747
+
660
748
  if (useManifest) await syncManifestManager.load();
661
-
749
+
662
750
  // Initialize progress tracking if a token is provided
663
751
  if (progressState && progressState.token) {
664
752
  server.notification({
@@ -675,6 +763,7 @@ async function syncFiles(client, useSFTP, localPath, remotePath, direction, igno
675
763
 
676
764
  if (extraExclude.length > 0) {
677
765
  ignorePatterns = [...ignorePatterns, ...extraExclude];
766
+ if (ignorePatterns._ig) delete ignorePatterns._ig;
678
767
  }
679
768
 
680
769
  if (direction === 'upload' || direction === 'both') {
@@ -700,11 +789,7 @@ async function syncFiles(client, useSFTP, localPath, remotePath, direction, igno
700
789
  try {
701
790
  if (file.isDirectory()) {
702
791
  if (!dryRun) {
703
- if (useSFTP) {
704
- await client.mkdir(remoteFilePath, true);
705
- } else {
706
- await client.ensureDir(remoteFilePath);
707
- }
792
+ await safeMkdir(client, useSFTP, remoteFilePath);
708
793
  }
709
794
  const subStats = await syncFiles(client, useSFTP, localFilePath, remoteFilePath, direction, ignorePatterns, basePath, extraExclude, dryRun, useManifest, false, progressState);
710
795
  stats.uploaded += subStats.uploaded;
@@ -726,9 +811,18 @@ async function syncFiles(client, useSFTP, localPath, remotePath, direction, igno
726
811
 
727
812
  if (shouldUpload) {
728
813
  try {
729
- const remoteStat = useSFTP
730
- ? await client.stat(remoteFilePath)
731
- : (await client.list(remotePath)).find(f => f.name === file.name);
814
+ let remoteStat = undefined;
815
+ if (useSFTP) {
816
+ remoteStat = await client.stat(remoteFilePath).catch(() => undefined);
817
+ } else {
818
+ // To avoid 1,000 MLSD calls, we fetch the directory list once if not cached
819
+ let dirList = getCached(`sync-${client.ftp?.host || 'local'}`, 'LIST', remotePath);
820
+ if (!dirList) {
821
+ dirList = await client.list(remotePath).catch(() => []);
822
+ setCached(`sync-${client.ftp?.host || 'local'}`, 'LIST', remotePath, dirList);
823
+ }
824
+ remoteStat = dirList.find(f => f.name === file.name);
825
+ }
732
826
 
733
827
  if (remoteStat) {
734
828
  const remoteTime = remoteStat.modifyTime || remoteStat.modifiedAt || new Date(remoteStat.rawModifiedAt);
@@ -1396,7 +1490,7 @@ server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
1396
1490
  if (request.params.uri === "mcp://instruction/server-state") {
1397
1491
  const isReadOnly = currentConfig?.readOnly || false;
1398
1492
  const protocol = currentConfig?.host?.startsWith('sftp://') ? 'SFTP' : 'FTP';
1399
-
1493
+
1400
1494
  const content = `# ftp-mcp Server Context (Agent Guide)
1401
1495
  **Current Version:** ${SERVER_VERSION}
1402
1496
  **Active Profile:** ${currentProfile || 'Environment Variables'}
@@ -1439,7 +1533,7 @@ server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
1439
1533
  } catch (e) {
1440
1534
  throw new Error(`Policy Violation: ${e.message}`);
1441
1535
  }
1442
-
1536
+
1443
1537
  try {
1444
1538
  const entry = await getClient(currentConfig);
1445
1539
  const content = await entry.execute(async (client) => {
@@ -1763,28 +1857,28 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1763
1857
 
1764
1858
  try {
1765
1859
  const entry = await getClient(currentConfig);
1766
- const client = entry.client;
1767
- const useSFTP = client._isSFTP;
1768
- const poolKey = getPoolKey(currentConfig);
1769
- const cmdName = request.params.name;
1770
- 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);
1771
-
1772
- const policyEngine = new PolicyEngine(currentConfig || {});
1773
-
1774
- if (isDestructive) {
1775
- if (currentConfig.readOnly) {
1776
- const errorResp = {
1777
- content: [{ type: "text", text: `Error: Profile '${currentProfile}' is configured in readOnly mode. Destructive actions are disabled.` }],
1778
- isError: true
1779
- };
1780
- await auditLog(cmdName, request.params.arguments, 'failed', currentProfile, 'readOnly mode violation');
1781
- return errorResp;
1860
+ const client = entry.client;
1861
+ const useSFTP = client._isSFTP;
1862
+ const poolKey = getPoolKey(currentConfig);
1863
+ const cmdName = request.params.name;
1864
+ 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);
1865
+
1866
+ const policyEngine = new PolicyEngine(currentConfig || {});
1867
+
1868
+ if (isDestructive) {
1869
+ if (currentConfig.readOnly) {
1870
+ const errorResp = {
1871
+ content: [{ type: "text", text: `Error: Profile '${currentProfile}' is configured in readOnly mode. Destructive actions are disabled.` }],
1872
+ isError: true
1873
+ };
1874
+ await auditLog(cmdName, request.params.arguments, 'failed', currentProfile, 'readOnly mode violation');
1875
+ return errorResp;
1876
+ }
1877
+ invalidatePoolCache(poolKey);
1782
1878
  }
1783
- invalidatePoolCache(poolKey);
1784
- }
1785
1879
 
1786
- const response = await entry.execute(async (client) => {
1787
- switch (cmdName) {
1880
+ const response = await entry.execute(async (client) => {
1881
+ switch (cmdName) {
1788
1882
  case "ftp_list": {
1789
1883
  const path = request.params.arguments?.path || ".";
1790
1884
  const limit = request.params.arguments?.limit || 100;
@@ -1804,7 +1898,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1804
1898
  const isDir = (useSFTP ? f.type === 'd' : f.isDirectory);
1805
1899
  const icon = isDir ? ICON.DIR : ICON.FILE;
1806
1900
  const label = isDir ? '[DIR] ' : '[FILE]';
1807
-
1901
+
1808
1902
  let marker = "";
1809
1903
  const nameLower = f.name.toLowerCase();
1810
1904
  if (['package.json', 'composer.json', 'requirements.txt', 'pyproject.toml', 'go.mod'].includes(nameLower)) marker = ` ${ICON.PKG}`;
@@ -1917,6 +2011,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1917
2011
 
1918
2012
  if (createBackup) {
1919
2013
  const backupPath = `${filePath}.bak`;
2014
+ await ensureRemoteDir(client, useSFTP, backupPath);
1920
2015
  if (useSFTP) {
1921
2016
  const buffer = Buffer.from(content, 'utf8');
1922
2017
  await client.put(buffer, backupPath);
@@ -1926,6 +2021,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1926
2021
  }
1927
2022
  }
1928
2023
 
2024
+ await ensureRemoteDir(client, useSFTP, filePath);
1929
2025
  if (useSFTP) {
1930
2026
  const buffer = Buffer.from(patchedContent, 'utf8');
1931
2027
  await client.put(buffer, filePath);
@@ -1950,6 +2046,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1950
2046
 
1951
2047
  const txId = await snapshotManager.createSnapshot(client, useSFTP, [filePath]);
1952
2048
 
2049
+ await ensureRemoteDir(client, useSFTP, filePath);
2050
+
1953
2051
  if (useSFTP) {
1954
2052
  const buffer = Buffer.from(content, 'utf8');
1955
2053
  await client.put(buffer, filePath);
@@ -2300,6 +2398,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2300
2398
  continue;
2301
2399
  }
2302
2400
  try {
2401
+ await ensureRemoteDir(client, useSFTP, file.remotePath);
2303
2402
  if (useSFTP) {
2304
2403
  await client.put(file.localPath, file.remotePath);
2305
2404
  } else {
@@ -2484,6 +2583,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2484
2583
 
2485
2584
  const txIdUpload = await snapshotManager.createSnapshot(client, useSFTP, [remotePath]);
2486
2585
 
2586
+ await ensureRemoteDir(client, useSFTP, remotePath);
2587
+
2487
2588
  if (useSFTP) {
2488
2589
  await client.put(localPath, remotePath);
2489
2590
  } else {
@@ -2544,11 +2645,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2544
2645
  case "ftp_mkdir": {
2545
2646
  const { path } = request.params.arguments;
2546
2647
 
2547
- if (useSFTP) {
2548
- await client.mkdir(path, true);
2549
- } else {
2550
- await client.ensureDir(path);
2551
- }
2648
+ await safeMkdir(client, useSFTP, path);
2552
2649
 
2553
2650
  return {
2554
2651
  content: [{ type: "text", text: `Successfully created directory ${path}` }]
@@ -2615,6 +2712,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2615
2712
 
2616
2713
  const txId = await snapshotManager.createSnapshot(client, useSFTP, [oldPath, newPath]);
2617
2714
 
2715
+ await ensureRemoteDir(client, useSFTP, newPath);
2618
2716
  await client.rename(oldPath, newPath);
2619
2717
 
2620
2718
  return {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ftp-mcp",
3
- "version": "1.5.1",
3
+ "version": "1.5.3",
4
4
  "description": "Enterprise-grade MCP server providing heavily optimized FTP/SFTP operations with smart sync, patch/chunk streaming, caching, and explicit read-only security mappings for AI code assistants.",
5
5
  "type": "module",
6
6
  "main": "index.js",