ftp-mcp 1.5.2 → 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 +101 -69
  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
 
@@ -413,7 +452,7 @@ function isSFTP(host) {
413
452
  async function connectFTP(config) {
414
453
  const client = new FTPClient();
415
454
  client.ftp.verbose = false;
416
-
455
+
417
456
  if (config.verbose) {
418
457
  client.ftp.log = (msg) => {
419
458
  server.sendLoggingMessage({
@@ -432,7 +471,7 @@ async function connectFTP(config) {
432
471
  secureOptions: config.secureOptions || { rejectUnauthorized: false }, // Default to false for easy dev if not specified
433
472
  timeout: config.timeout || 30000 // 30s connection timeout
434
473
  });
435
-
474
+
436
475
  try {
437
476
  client._initialPwd = await client.pwd();
438
477
  } catch (e) {
@@ -535,7 +574,7 @@ async function getClient(config) {
535
574
  telemetry.activeConnections++;
536
575
 
537
576
  client._isSFTP = useSFTP;
538
-
577
+
539
578
  // Silence MaxListenersExceededWarning during high-activity syncs/sessions
540
579
  if (typeof client.setMaxListeners === 'function') {
541
580
  client.setMaxListeners(100);
@@ -550,7 +589,7 @@ async function getClient(config) {
550
589
  async execute(task) {
551
590
  // Use a simple promise chain to serialize operations on this client
552
591
  const result = this.promiseQueue.then(() => task(this.client));
553
- this.promiseQueue = result.catch(() => {}); // Continue queue even on error
592
+ this.promiseQueue = result.catch(() => { }); // Continue queue even on error
554
593
  return result;
555
594
  }
556
595
  };
@@ -684,29 +723,30 @@ async function syncFiles(client, useSFTP, localPath, remotePath, direction, igno
684
723
  // --- ANTI-RECURSION GUARD ---
685
724
  // If we're syncing the current directory (.), and the remote target is a local folder (e.g. 'FlowdexMCP_LiveTest'),
686
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.
687
727
  const resolvedLocal = path.resolve(localPath);
688
728
  // Rough check: if remotePath is just a folder name (no slashes) or a relative path, ignore it
689
729
  if (remotePath && typeof remotePath === 'string' && !remotePath.startsWith('/')) {
690
- const firstFolder = remotePath.split('/')[0];
691
- if (firstFolder && firstFolder !== '.') {
692
- ignorePatterns.push(firstFolder);
693
- ignorePatterns.push(`${firstFolder}/**`);
694
-
695
- // Re-instantiate the Ignore instance since we appended pattern strings
696
- if (ignorePatterns._ig) delete ignorePatterns._ig;
730
+ const firstFolder = remotePath.split('/')[0];
731
+ if (firstFolder && firstFolder !== '.') {
732
+ ignorePatterns.push(firstFolder);
733
+ ignorePatterns.push(`${firstFolder}/**`);
697
734
 
698
- server.notification({
699
- method: "notifications/message",
700
- params: {
701
- level: "info",
702
- data: { message: `[SYNC GUARD] Excluded target '${firstFolder}' from local sync to prevent infinite recursion.` }
703
- }
704
- });
705
- }
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
+ }
706
746
  }
707
747
 
708
748
  if (useManifest) await syncManifestManager.load();
709
-
749
+
710
750
  // Initialize progress tracking if a token is provided
711
751
  if (progressState && progressState.token) {
712
752
  server.notification({
@@ -749,12 +789,7 @@ async function syncFiles(client, useSFTP, localPath, remotePath, direction, igno
749
789
  try {
750
790
  if (file.isDirectory()) {
751
791
  if (!dryRun) {
752
- if (useSFTP) {
753
- await client.mkdir(remoteFilePath, true);
754
- } else {
755
- await client.ensureDir(remoteFilePath);
756
- if (client._initialPwd) await client.cd(client._initialPwd).catch(() => {});
757
- }
792
+ await safeMkdir(client, useSFTP, remoteFilePath);
758
793
  }
759
794
  const subStats = await syncFiles(client, useSFTP, localFilePath, remoteFilePath, direction, ignorePatterns, basePath, extraExclude, dryRun, useManifest, false, progressState);
760
795
  stats.uploaded += subStats.uploaded;
@@ -778,15 +813,15 @@ async function syncFiles(client, useSFTP, localPath, remotePath, direction, igno
778
813
  try {
779
814
  let remoteStat = undefined;
780
815
  if (useSFTP) {
781
- remoteStat = await client.stat(remoteFilePath).catch(() => undefined);
816
+ remoteStat = await client.stat(remoteFilePath).catch(() => undefined);
782
817
  } else {
783
- // To avoid 1,000 MLSD calls, we fetch the directory list once if not cached
784
- let dirList = getCached(`sync-${client.ftp?.host || 'local'}`, 'LIST', remotePath);
785
- if (!dirList) {
786
- dirList = await client.list(remotePath).catch(() => []);
787
- setCached(`sync-${client.ftp?.host || 'local'}`, 'LIST', remotePath, dirList);
788
- }
789
- remoteStat = dirList.find(f => f.name === file.name);
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);
790
825
  }
791
826
 
792
827
  if (remoteStat) {
@@ -1455,7 +1490,7 @@ server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
1455
1490
  if (request.params.uri === "mcp://instruction/server-state") {
1456
1491
  const isReadOnly = currentConfig?.readOnly || false;
1457
1492
  const protocol = currentConfig?.host?.startsWith('sftp://') ? 'SFTP' : 'FTP';
1458
-
1493
+
1459
1494
  const content = `# ftp-mcp Server Context (Agent Guide)
1460
1495
  **Current Version:** ${SERVER_VERSION}
1461
1496
  **Active Profile:** ${currentProfile || 'Environment Variables'}
@@ -1498,7 +1533,7 @@ server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
1498
1533
  } catch (e) {
1499
1534
  throw new Error(`Policy Violation: ${e.message}`);
1500
1535
  }
1501
-
1536
+
1502
1537
  try {
1503
1538
  const entry = await getClient(currentConfig);
1504
1539
  const content = await entry.execute(async (client) => {
@@ -1822,28 +1857,28 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1822
1857
 
1823
1858
  try {
1824
1859
  const entry = await getClient(currentConfig);
1825
- const client = entry.client;
1826
- const useSFTP = client._isSFTP;
1827
- const poolKey = getPoolKey(currentConfig);
1828
- const cmdName = request.params.name;
1829
- 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);
1830
-
1831
- const policyEngine = new PolicyEngine(currentConfig || {});
1832
-
1833
- if (isDestructive) {
1834
- if (currentConfig.readOnly) {
1835
- const errorResp = {
1836
- content: [{ type: "text", text: `Error: Profile '${currentProfile}' is configured in readOnly mode. Destructive actions are disabled.` }],
1837
- isError: true
1838
- };
1839
- await auditLog(cmdName, request.params.arguments, 'failed', currentProfile, 'readOnly mode violation');
1840
- 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);
1841
1878
  }
1842
- invalidatePoolCache(poolKey);
1843
- }
1844
1879
 
1845
- const response = await entry.execute(async (client) => {
1846
- switch (cmdName) {
1880
+ const response = await entry.execute(async (client) => {
1881
+ switch (cmdName) {
1847
1882
  case "ftp_list": {
1848
1883
  const path = request.params.arguments?.path || ".";
1849
1884
  const limit = request.params.arguments?.limit || 100;
@@ -1863,7 +1898,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1863
1898
  const isDir = (useSFTP ? f.type === 'd' : f.isDirectory);
1864
1899
  const icon = isDir ? ICON.DIR : ICON.FILE;
1865
1900
  const label = isDir ? '[DIR] ' : '[FILE]';
1866
-
1901
+
1867
1902
  let marker = "";
1868
1903
  const nameLower = f.name.toLowerCase();
1869
1904
  if (['package.json', 'composer.json', 'requirements.txt', 'pyproject.toml', 'go.mod'].includes(nameLower)) marker = ` ${ICON.PKG}`;
@@ -1976,6 +2011,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1976
2011
 
1977
2012
  if (createBackup) {
1978
2013
  const backupPath = `${filePath}.bak`;
2014
+ await ensureRemoteDir(client, useSFTP, backupPath);
1979
2015
  if (useSFTP) {
1980
2016
  const buffer = Buffer.from(content, 'utf8');
1981
2017
  await client.put(buffer, backupPath);
@@ -1985,6 +2021,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1985
2021
  }
1986
2022
  }
1987
2023
 
2024
+ await ensureRemoteDir(client, useSFTP, filePath);
1988
2025
  if (useSFTP) {
1989
2026
  const buffer = Buffer.from(patchedContent, 'utf8');
1990
2027
  await client.put(buffer, filePath);
@@ -2009,6 +2046,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2009
2046
 
2010
2047
  const txId = await snapshotManager.createSnapshot(client, useSFTP, [filePath]);
2011
2048
 
2049
+ await ensureRemoteDir(client, useSFTP, filePath);
2050
+
2012
2051
  if (useSFTP) {
2013
2052
  const buffer = Buffer.from(content, 'utf8');
2014
2053
  await client.put(buffer, filePath);
@@ -2359,6 +2398,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2359
2398
  continue;
2360
2399
  }
2361
2400
  try {
2401
+ await ensureRemoteDir(client, useSFTP, file.remotePath);
2362
2402
  if (useSFTP) {
2363
2403
  await client.put(file.localPath, file.remotePath);
2364
2404
  } else {
@@ -2543,15 +2583,11 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2543
2583
 
2544
2584
  const txIdUpload = await snapshotManager.createSnapshot(client, useSFTP, [remotePath]);
2545
2585
 
2586
+ await ensureRemoteDir(client, useSFTP, remotePath);
2587
+
2546
2588
  if (useSFTP) {
2547
- await client.mkdir(path.dirname(remotePath), true).catch(() => {});
2548
2589
  await client.put(localPath, remotePath);
2549
2590
  } else {
2550
- const dirName = remotePath.split('/').slice(0, -1).join('/');
2551
- if (dirName && dirName !== '.') {
2552
- await client.ensureDir(dirName).catch(() => {});
2553
- await client.cd(client._initialPwd || '/').catch(() => {}); // reset to root
2554
- }
2555
2591
  await client.uploadFrom(localPath, remotePath);
2556
2592
  }
2557
2593
 
@@ -2609,12 +2645,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2609
2645
  case "ftp_mkdir": {
2610
2646
  const { path } = request.params.arguments;
2611
2647
 
2612
- if (useSFTP) {
2613
- await client.mkdir(path, true);
2614
- } else {
2615
- await client.ensureDir(path);
2616
- if (client._initialPwd) await client.cd(client._initialPwd).catch(() => {});
2617
- }
2648
+ await safeMkdir(client, useSFTP, path);
2618
2649
 
2619
2650
  return {
2620
2651
  content: [{ type: "text", text: `Successfully created directory ${path}` }]
@@ -2681,6 +2712,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2681
2712
 
2682
2713
  const txId = await snapshotManager.createSnapshot(client, useSFTP, [oldPath, newPath]);
2683
2714
 
2715
+ await ensureRemoteDir(client, useSFTP, newPath);
2684
2716
  await client.rename(oldPath, newPath);
2685
2717
 
2686
2718
  return {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ftp-mcp",
3
- "version": "1.5.2",
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",