ftp-mcp 1.2.2 → 1.3.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
@@ -21,6 +21,17 @@ import { SnapshotManager } from "./snapshot-manager.js";
21
21
  import { PolicyEngine } from "./policy-engine.js";
22
22
  import { SyncManifestManager } from "./sync-manifest.js";
23
23
  import crypto from "crypto";
24
+
25
+ const __filename = fileURLToPath(import.meta.url);
26
+ const __dirname = path.dirname(__filename);
27
+
28
+ // Read version from package.json to avoid version drift (CODE-1)
29
+ let SERVER_VERSION = "1.3.0";
30
+ try {
31
+ const pkg = JSON.parse(readFileSync(path.join(__dirname, 'package.json'), 'utf8'));
32
+ SERVER_VERSION = pkg.version || SERVER_VERSION;
33
+ } catch (e) { /* fallback to hardcoded */ }
34
+
24
35
  const DEFAULT_CONFIG_NAME = ".ftpconfig";
25
36
  const CONFIG_FILE = process.env.FTP_CONFIG_PATH || path.join(process.cwd(), DEFAULT_CONFIG_NAME);
26
37
 
@@ -162,9 +173,64 @@ function isSecretFile(filePath) {
162
173
  name.includes('token');
163
174
  }
164
175
 
176
+ /**
177
+ * SEC-1/2/3: Validate that a local path stays within the configured safe root.
178
+ * Prevents path traversal / arbitrary file read-write attacks.
179
+ * If no safeRoot configured, defaults to user home directory as a broad guard.
180
+ */
181
+ function validateLocalPath(localPath, safeRoot = null) {
182
+ const resolved = path.resolve(localPath);
183
+ const root = safeRoot ? path.resolve(safeRoot) : null;
184
+ if (root) {
185
+ if (!resolved.startsWith(root + path.sep) && resolved !== root) {
186
+ throw new Error(`Security: Local path '${localPath}' is outside the allowed directory '${root}'.`);
187
+ }
188
+ }
189
+ // Always block traversal separators in the raw input regardless of root config
190
+ const normalized = localPath.replace(/\\/g, '/');
191
+ if (normalized.includes('../')) {
192
+ throw new Error(`Security: Path traversal detected in local path '${localPath}'.`);
193
+ }
194
+ return resolved;
195
+ }
196
+
197
+ /**
198
+ * SEC-4: Safely compile a user-supplied regex pattern.
199
+ * Returns null and an error message if the pattern is invalid or too long.
200
+ */
201
+ function safeRegex(pattern, flags = 'i') {
202
+ if (!pattern || typeof pattern !== 'string') return { regex: null, error: null };
203
+ if (pattern.length > 250) {
204
+ return { regex: null, error: 'Regex pattern too long (max 250 chars).' };
205
+ }
206
+ try {
207
+ return { regex: new RegExp(pattern, flags), error: null };
208
+ } catch (e) {
209
+ return { regex: null, error: `Invalid regex: ${e.message}` };
210
+ }
211
+ }
212
+
213
+ /**
214
+ * CODE-5: Reject dangerously shallow remote paths on destructive operations.
215
+ * Prevents accidental or malicious deletion of root-level directories.
216
+ */
217
+ function assertSafeRemotePath(remotePath) {
218
+ const clean = (remotePath || '').replace(/\/+$/, '') || '/';
219
+ const depth = clean.split('/').filter(Boolean).length;
220
+ if (depth < 1 || clean === '/') {
221
+ throw new Error(`Safety: Refusing to operate on root path '${clean}'. Provide a more specific path.`);
222
+ }
223
+ // Also block well-known dangerous Unix roots
224
+ const dangerous = ['/etc', '/bin', '/sbin', '/usr', '/var', '/lib', '/home', '/root', '/boot', '/dev', '/proc', '/sys'];
225
+ if (dangerous.includes(clean)) {
226
+ throw new Error(`Safety: Refusing to operate on system path '${clean}'.`);
227
+ }
228
+ }
229
+
165
230
  function shouldIgnore(filePath, ignorePatterns, basePath) {
166
231
  const relativePath = path.relative(basePath, filePath).replace(/\\/g, '/');
167
232
 
233
+
168
234
  if (!ignorePatterns._ig) {
169
235
  Object.defineProperty(ignorePatterns, '_ig', {
170
236
  value: ignore().add(ignorePatterns),
@@ -243,13 +309,15 @@ async function loadFTPConfig(profileName = null, forceEnv = false) {
243
309
  }
244
310
 
245
311
  function getPort(host, configPort) {
246
- if (configPort) return parseInt(configPort);
247
- if (host && (host.includes('sftp') || host.startsWith('sftp://'))) return 22;
312
+ if (configPort) return parseInt(configPort, 10);
313
+ // LOW-4: Use strict prefix check, not includes() which matches e.g. "mysftp-server.com"
314
+ if (host && host.startsWith('sftp://')) return 22;
248
315
  return 21;
249
316
  }
250
317
 
251
318
  function isSFTP(host) {
252
- return host && (host.includes('sftp') || host.startsWith('sftp://'));
319
+ // LOW-4: Only match the sftp:// protocol prefix
320
+ return !!(host && host.startsWith('sftp://'));
253
321
  }
254
322
 
255
323
  async function connectFTP(config) {
@@ -308,12 +376,19 @@ function getCached(poolKey, type, path) {
308
376
  telemetry.cacheHits++;
309
377
  return entry.data;
310
378
  }
379
+ // CODE-3: Evict stale entry while we're here
380
+ if (entry) dirCache.delete(`${poolKey}:${type}:${path}`);
311
381
  telemetry.cacheMisses++;
312
382
  return null;
313
383
  }
314
384
 
315
385
  function setCached(poolKey, type, path, data) {
316
386
  dirCache.set(`${poolKey}:${type}:${path}`, { timestamp: Date.now(), data });
387
+ // CODE-3: Evict any entries older than TTL to prevent unbounded growth
388
+ const now = Date.now();
389
+ for (const [key, entry] of dirCache.entries()) {
390
+ if (now - entry.timestamp >= CACHE_TTL) dirCache.delete(key);
391
+ }
317
392
  }
318
393
 
319
394
  function invalidatePoolCache(poolKey) {
@@ -432,7 +507,8 @@ async function auditLog(toolName, args, status, user, errorMsg = null) {
432
507
  };
433
508
  await fs.appendFile(path.join(process.cwd(), '.ftp-mcp-audit.log'), JSON.stringify(logEntry) + '\n', 'utf8');
434
509
  } catch (e) {
435
- // safely ignore audit log failures for now
510
+ // QUAL-5: Emit to stderr so audit failures are not silently swallowed
511
+ console.error('[ftp-mcp] Warning: Failed to write audit log:', e.message);
436
512
  }
437
513
  }
438
514
 
@@ -663,7 +739,7 @@ function generateSemanticPreview(filesToChange) {
663
739
  const server = new Server(
664
740
  {
665
741
  name: "ftp-mcp-server",
666
- version: "1.2.1",
742
+ version: SERVER_VERSION, // CODE-1: reads from package.json at startup
667
743
  },
668
744
  {
669
745
  capabilities: {
@@ -986,8 +1062,9 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
986
1062
  },
987
1063
  direction: {
988
1064
  type: "string",
989
- description: "Sync direction: 'upload', 'download', or 'both'",
990
- enum: ["upload", "download", "both"],
1065
+ // QUAL-2: Only 'upload' is implemented; removed 'download'/'both' to avoid silent no-ops
1066
+ description: "Sync direction: currently only 'upload' is supported",
1067
+ enum: ["upload"],
991
1068
  default: "upload"
992
1069
  },
993
1070
  dryRun: {
@@ -1247,16 +1324,17 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1247
1324
  };
1248
1325
  }
1249
1326
 
1250
- currentConfig = profileConfig;
1251
- currentProfile = deployConfig.profile;
1252
-
1253
- const useSFTP = isSFTP(currentConfig.host);
1254
- const client = await getClient(currentConfig);
1327
+ // CODE-2: Use a local-scoped config to avoid mutating the module-level
1328
+ // currentConfig/currentProfile globals, which would corrupt subsequent tool calls.
1329
+ const deployProfileConfig = profileConfig;
1330
+ const deployProfileName = deployConfig.profile;
1331
+ const useSFTP = isSFTP(deployProfileConfig.host);
1332
+ const deployEntry = await getClient(deployProfileConfig);
1255
1333
 
1256
1334
  try {
1257
1335
  const localPath = path.resolve(deployConfig.local);
1258
1336
  const stats = await syncFiles(
1259
- client,
1337
+ deployEntry.client,
1260
1338
  useSFTP,
1261
1339
  localPath,
1262
1340
  deployConfig.remote,
@@ -1269,11 +1347,11 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1269
1347
  return {
1270
1348
  content: [{
1271
1349
  type: "text",
1272
- text: `Deployment "${deployment}" complete:\n${deployConfig.description || ''}\n\nProfile: ${deployConfig.profile}\nLocal: ${deployConfig.local}\nRemote: ${deployConfig.remote}\n\nUploaded: ${stats.uploaded}\nSkipped: ${stats.skipped}\nIgnored: ${stats.ignored}\n${stats.errors.length > 0 ? '\nErrors:\n' + stats.errors.join('\n') : ''}`
1350
+ text: `Deployment "${deployment}" complete:\n${deployConfig.description || ''}\n\nProfile: ${deployProfileName}\nLocal: ${deployConfig.local}\nRemote: ${deployConfig.remote}\n\nUploaded: ${stats.uploaded}\nSkipped: ${stats.skipped}\nIgnored: ${stats.ignored}\n${stats.errors.length > 0 ? '\nErrors:\n' + stats.errors.join('\n') : ''}`
1273
1351
  }]
1274
1352
  };
1275
1353
  } finally {
1276
- releaseClient(currentConfig);
1354
+ releaseClient(deployProfileConfig);
1277
1355
  }
1278
1356
  } catch (error) {
1279
1357
  return {
@@ -1301,6 +1379,18 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1301
1379
  };
1302
1380
  }
1303
1381
 
1382
+ // LOW-5: Perform a real connection test so credential errors surface early
1383
+ try {
1384
+ const testEntry = await getClient(currentConfig);
1385
+ await testEntry.execute(c => c.list('.'));
1386
+ } catch (connErr) {
1387
+ currentConfig = null;
1388
+ return {
1389
+ content: [{ type: "text", text: `Error: Could not connect — ${connErr.message}` }],
1390
+ isError: true
1391
+ };
1392
+ }
1393
+
1304
1394
  let warning = "";
1305
1395
  const isProd = (profile || currentProfile || '').toLowerCase().includes('prod');
1306
1396
  if (isProd && !isSFTP(currentConfig.host)) {
@@ -1540,10 +1630,10 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1540
1630
  }
1541
1631
 
1542
1632
  case "ftp_stat": {
1543
- const { path } = request.params.arguments;
1633
+ const { path: filePath } = request.params.arguments;
1544
1634
 
1545
1635
  if (useSFTP) {
1546
- const stats = await client.stat(path);
1636
+ const stats = await client.stat(filePath);
1547
1637
  return {
1548
1638
  content: [{
1549
1639
  type: "text",
@@ -1558,13 +1648,14 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1558
1648
  }]
1559
1649
  };
1560
1650
  } else {
1561
- const dirPath = path.substring(0, path.lastIndexOf('/')) || '.';
1562
- const fileName = path.substring(path.lastIndexOf('/') + 1);
1563
- const files = await client.list(dirPath);
1651
+ // CODE-4: use filePath (not path module) for string operations
1652
+ const dirPart = filePath.substring(0, filePath.lastIndexOf('/')) || '.';
1653
+ const fileName = filePath.substring(filePath.lastIndexOf('/') + 1);
1654
+ const files = await client.list(dirPart);
1564
1655
  const file = files.find(f => f.name === fileName);
1565
1656
 
1566
1657
  if (!file) {
1567
- throw new Error(`File not found: ${path}`);
1658
+ throw new Error(`File not found: ${filePath}`);
1568
1659
  }
1569
1660
 
1570
1661
  return {
@@ -1582,17 +1673,18 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1582
1673
  }
1583
1674
 
1584
1675
  case "ftp_exists": {
1585
- const { path } = request.params.arguments;
1676
+ const { path: filePath } = request.params.arguments;
1586
1677
  let exists = false;
1587
1678
 
1588
1679
  try {
1589
1680
  if (useSFTP) {
1590
- await client.stat(path);
1681
+ await client.stat(filePath);
1591
1682
  exists = true;
1592
1683
  } else {
1593
- const dirPath = path.substring(0, path.lastIndexOf('/')) || '.';
1594
- const fileName = path.substring(path.lastIndexOf('/') + 1);
1595
- const files = await client.list(dirPath);
1684
+ // CODE-4: use filePath string, not the path module
1685
+ const dirPart = filePath.substring(0, filePath.lastIndexOf('/')) || '.';
1686
+ const fileName = filePath.substring(filePath.lastIndexOf('/') + 1);
1687
+ const files = await client.list(dirPart);
1596
1688
  exists = files.some(f => f.name === fileName);
1597
1689
  }
1598
1690
  } catch (e) {
@@ -1630,6 +1722,22 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1630
1722
  return { content: [{ type: "text", text: "Error: Must provide pattern, contentPattern, or findLikelyConfigs" }], isError: true };
1631
1723
  }
1632
1724
 
1725
+ // SEC-4: Validate user-supplied regex patterns to prevent ReDoS
1726
+ let compiledPattern = null;
1727
+ if (pattern) {
1728
+ const safeGlob = pattern.replace(/\*/g, '.*').replace(/\?/g, '.');
1729
+ const result = safeRegex(safeGlob);
1730
+ if (result.error) return { content: [{ type: "text", text: `Error: ${result.error}` }], isError: true };
1731
+ compiledPattern = result.regex;
1732
+ }
1733
+
1734
+ let compiledContentPattern = null;
1735
+ if (contentPattern) {
1736
+ const result = safeRegex(contentPattern);
1737
+ if (result.error) return { content: [{ type: "text", text: `Error: ${result.error}` }], isError: true };
1738
+ compiledContentPattern = result.regex;
1739
+ }
1740
+
1633
1741
  const cacheKey = `${searchPath}:10`;
1634
1742
  let tree = getCached(poolKey, 'TREE', cacheKey);
1635
1743
  if (!tree) {
@@ -1644,9 +1752,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1644
1752
  matches = matches.filter(item => configRegex.test(item.name));
1645
1753
  }
1646
1754
 
1647
- if (pattern) {
1648
- const regex = new RegExp(pattern.replace(/\*/g, '.*').replace(/\?/g, '.'), 'i');
1649
- matches = matches.filter(item => regex.test(item.name));
1755
+ if (compiledPattern) {
1756
+ matches = matches.filter(item => compiledPattern.test(item.name));
1650
1757
  }
1651
1758
 
1652
1759
  if (extension) {
@@ -1658,8 +1765,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1658
1765
  let sliced = matches.slice(offset, offset + limit);
1659
1766
  let formatted = "";
1660
1767
 
1661
- if (contentPattern) {
1662
- const contentRegex = new RegExp(contentPattern, 'i');
1768
+ if (compiledContentPattern) {
1663
1769
  const contentMatches = [];
1664
1770
 
1665
1771
  for (const item of sliced) {
@@ -1678,12 +1784,12 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1678
1784
 
1679
1785
  const lines = content.split('\n');
1680
1786
  for (let i = 0; i < lines.length; i++) {
1681
- if (contentRegex.test(lines[i])) {
1787
+ if (compiledContentPattern.test(lines[i])) {
1682
1788
  const start = Math.max(0, i - 1);
1683
1789
  const end = Math.min(lines.length - 1, i + 1);
1684
1790
  const context = lines.slice(start, end + 1).map((l, idx) => `${start + idx + 1}: ${l}`).join('\n');
1685
1791
  contentMatches.push(`File: ${item.path}\n${context}\n---`);
1686
- break; // Just show first match per file to save space
1792
+ break;
1687
1793
  }
1688
1794
  }
1689
1795
  } catch (e) {
@@ -1804,6 +1910,14 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1804
1910
  };
1805
1911
  }
1806
1912
 
1913
+ // LOW-3: Validate both paths against policy engine
1914
+ try {
1915
+ policyEngine.validateOperation('overwrite', { path: destPath });
1916
+ policyEngine.validateOperation('patch', { path: sourcePath });
1917
+ } catch (e) {
1918
+ return { content: [{ type: "text", text: e.message }], isError: true };
1919
+ }
1920
+
1807
1921
  const buffer = await client.get(sourcePath);
1808
1922
  await client.put(buffer, destPath);
1809
1923
 
@@ -1815,8 +1929,26 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1815
1929
  case "ftp_batch_upload": {
1816
1930
  const { files } = request.params.arguments;
1817
1931
  const results = { success: [], failed: [] };
1932
+ const snapshotPaths = files.map(f => f.remotePath);
1933
+ const batchTxId = await snapshotManager.createSnapshot(client, useSFTP, snapshotPaths);
1818
1934
 
1819
1935
  for (const file of files) {
1936
+ // CODE-6/SEC-3: Apply same security guards as ftp_upload
1937
+ try { validateLocalPath(file.localPath); } catch (e) {
1938
+ results.failed.push({ path: file.remotePath, error: e.message });
1939
+ continue;
1940
+ }
1941
+ if (isSecretFile(file.localPath)) {
1942
+ results.failed.push({ path: file.remotePath, error: `Blocked: likely secret file` });
1943
+ continue;
1944
+ }
1945
+ try {
1946
+ const stat = await fs.stat(file.localPath);
1947
+ policyEngine.validateOperation('overwrite', { path: file.remotePath, size: stat.size });
1948
+ } catch (e) {
1949
+ results.failed.push({ path: file.remotePath, error: e.message });
1950
+ continue;
1951
+ }
1820
1952
  try {
1821
1953
  if (useSFTP) {
1822
1954
  await client.put(file.localPath, file.remotePath);
@@ -1832,7 +1964,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1832
1964
  return {
1833
1965
  content: [{
1834
1966
  type: "text",
1835
- 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') : ''}`
1967
+ text: `Uploaded: ${results.success.length}\nFailed: ${results.failed.length}\nTransaction ID: ${batchTxId}\n${results.failed.length > 0 ? '\nErrors:\n' + results.failed.map(f => `${f.path}: ${f.error}`).join('\n') : ''}`
1836
1968
  }]
1837
1969
  };
1838
1970
  }
@@ -1842,6 +1974,11 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1842
1974
  const results = { success: [], failed: [] };
1843
1975
 
1844
1976
  for (const file of files) {
1977
+ // CODE-7/SEC-2: Validate local paths to prevent path traversal
1978
+ try { validateLocalPath(file.localPath); } catch (e) {
1979
+ results.failed.push({ path: file.remotePath, error: e.message });
1980
+ continue;
1981
+ }
1845
1982
  try {
1846
1983
  if (useSFTP) {
1847
1984
  await client.get(file.remotePath, file.localPath);
@@ -1926,6 +2063,11 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1926
2063
  case "ftp_upload": {
1927
2064
  const { localPath, remotePath } = request.params.arguments;
1928
2065
 
2066
+ // SEC-3: Block path traversal and enforce local path safety
2067
+ try { validateLocalPath(localPath); } catch (e) {
2068
+ return { content: [{ type: "text", text: e.message }], isError: true };
2069
+ }
2070
+
1929
2071
  if (isSecretFile(localPath)) {
1930
2072
  return {
1931
2073
  content: [{ type: "text", text: `Security Warning: Blocked upload of likely secret file: ${localPath}` }],
@@ -1940,7 +2082,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1940
2082
  return { content: [{ type: "text", text: e.message }], isError: true };
1941
2083
  }
1942
2084
 
1943
- const txId = await snapshotManager.createSnapshot(client, useSFTP, [remotePath]);
2085
+ const txIdUpload = await snapshotManager.createSnapshot(client, useSFTP, [remotePath]);
1944
2086
 
1945
2087
  if (useSFTP) {
1946
2088
  await client.put(localPath, remotePath);
@@ -1949,13 +2091,18 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1949
2091
  }
1950
2092
 
1951
2093
  return {
1952
- content: [{ type: "text", text: `Successfully uploaded ${localPath} to ${remotePath}\nTransaction ID: ${txId}` }]
2094
+ content: [{ type: "text", text: `Successfully uploaded ${localPath} to ${remotePath}\nTransaction ID: ${txIdUpload}` }]
1953
2095
  };
1954
2096
  }
1955
2097
 
1956
2098
  case "ftp_download": {
1957
2099
  const { remotePath, localPath } = request.params.arguments;
1958
2100
 
2101
+ // SEC-1: Block path traversal and enforce local path safety
2102
+ try { validateLocalPath(localPath); } catch (e) {
2103
+ return { content: [{ type: "text", text: e.message }], isError: true };
2104
+ }
2105
+
1959
2106
  if (useSFTP) {
1960
2107
  await client.get(remotePath, localPath);
1961
2108
  } else {
@@ -1970,13 +2117,18 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1970
2117
  case "ftp_delete": {
1971
2118
  const { path: filePath } = request.params.arguments;
1972
2119
 
2120
+ // CODE-5: Block dangerous root-level paths
2121
+ try { assertSafeRemotePath(filePath); } catch (e) {
2122
+ return { content: [{ type: "text", text: e.message }], isError: true };
2123
+ }
2124
+
1973
2125
  try {
1974
2126
  policyEngine.validateOperation('delete', { path: filePath });
1975
2127
  } catch (e) {
1976
2128
  return { content: [{ type: "text", text: e.message }], isError: true };
1977
2129
  }
1978
2130
 
1979
- const txId = await snapshotManager.createSnapshot(client, useSFTP, [filePath]);
2131
+ const txIdDelete = await snapshotManager.createSnapshot(client, useSFTP, [filePath]);
1980
2132
 
1981
2133
  if (useSFTP) {
1982
2134
  await client.delete(filePath);
@@ -1985,7 +2137,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1985
2137
  }
1986
2138
 
1987
2139
  return {
1988
- content: [{ type: "text", text: `Successfully deleted ${filePath}\nTransaction ID: ${txId}` }]
2140
+ content: [{ type: "text", text: `Successfully deleted ${filePath}\nTransaction ID: ${txIdDelete}` }]
1989
2141
  };
1990
2142
  }
1991
2143
 
@@ -2004,25 +2156,30 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2004
2156
  }
2005
2157
 
2006
2158
  case "ftp_rmdir": {
2007
- const { path, recursive } = request.params.arguments;
2159
+ const { path: rmPath, recursive } = request.params.arguments;
2160
+
2161
+ // CODE-5: Block dangerous root-level paths
2162
+ try { assertSafeRemotePath(rmPath); } catch (e) {
2163
+ return { content: [{ type: "text", text: e.message }], isError: true };
2164
+ }
2008
2165
 
2009
2166
  if (useSFTP) {
2010
- await client.rmdir(path, recursive);
2167
+ await client.rmdir(rmPath, recursive);
2011
2168
  } else {
2012
2169
  if (recursive) {
2013
- await client.removeDir(path);
2170
+ await client.removeDir(rmPath);
2014
2171
  } else {
2015
- await client.send("RMD", path);
2172
+ await client.removeEmptyDir(rmPath);
2016
2173
  }
2017
2174
  }
2018
2175
 
2019
2176
  return {
2020
- content: [{ type: "text", text: `Successfully removed directory ${path}` }]
2177
+ content: [{ type: "text", text: `Successfully removed directory ${rmPath}` }]
2021
2178
  };
2022
2179
  }
2023
2180
 
2024
2181
  case "ftp_chmod": {
2025
- const { path, mode } = request.params.arguments;
2182
+ const { path: chmodPath, mode } = request.params.arguments;
2026
2183
 
2027
2184
  if (!useSFTP) {
2028
2185
  return {
@@ -2030,10 +2187,19 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2030
2187
  };
2031
2188
  }
2032
2189
 
2033
- await client.chmod(path, mode);
2190
+ // LOW-2: Parse octal string to number; reject invalid modes
2191
+ const modeInt = parseInt(mode, 8);
2192
+ if (isNaN(modeInt) || modeInt < 0 || modeInt > 0o7777) {
2193
+ return {
2194
+ content: [{ type: "text", text: `Error: Invalid chmod mode '${mode}'. Use octal notation e.g. '755'.` }],
2195
+ isError: true
2196
+ };
2197
+ }
2198
+
2199
+ await client.chmod(chmodPath, modeInt);
2034
2200
 
2035
2201
  return {
2036
- content: [{ type: "text", text: `Successfully changed permissions of ${path} to ${mode}` }]
2202
+ content: [{ type: "text", text: `Successfully changed permissions of ${chmodPath} to ${mode}` }]
2037
2203
  };
2038
2204
  }
2039
2205
 
@@ -2212,7 +2378,27 @@ async function main() {
2212
2378
  console.error("FTP MCP Server running on stdio");
2213
2379
  }
2214
2380
 
2381
+ // LOW-1: Graceful shutdown — close all pooled connections before exiting
2382
+ async function shutdown(signal) {
2383
+ console.error(`[ftp-mcp] Received ${signal}, closing connections...`);
2384
+ for (const [poolKey, entry] of connectionPool.entries()) {
2385
+ try {
2386
+ if (!entry.closed) {
2387
+ entry.closed = true;
2388
+ if (entry.client._isSFTP) await entry.client.end();
2389
+ else entry.client.close();
2390
+ }
2391
+ } catch (e) { /* ignore */ }
2392
+ }
2393
+ connectionPool.clear();
2394
+ process.exit(0);
2395
+ }
2396
+
2397
+ process.on('SIGINT', () => shutdown('SIGINT'));
2398
+ process.on('SIGTERM', () => shutdown('SIGTERM'));
2399
+
2215
2400
  main().catch((error) => {
2216
2401
  console.error("Fatal error:", error);
2217
2402
  process.exit(1);
2218
2403
  });
2404
+
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ftp-mcp",
3
- "version": "1.2.2",
3
+ "version": "1.3.0",
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",
@@ -3,6 +3,8 @@ import path from 'path';
3
3
  import crypto from 'crypto';
4
4
  import { Writable } from 'stream';
5
5
 
6
+ const MAX_SNAPSHOTS = 50;
7
+
6
8
  export class SnapshotManager {
7
9
  constructor(baseDir = process.cwd()) {
8
10
  this.snapshotDir = path.join(baseDir, '.ftp-mcp-snapshots');
@@ -16,6 +18,24 @@ export class SnapshotManager {
16
18
  }
17
19
  }
18
20
 
21
+ async pruneOldSnapshots(maxToKeep = MAX_SNAPSHOTS) {
22
+ try {
23
+ const dirs = await fs.readdir(this.snapshotDir, { withFileTypes: true });
24
+ const txDirs = dirs
25
+ .filter(d => d.isDirectory() && d.name.startsWith('tx_'))
26
+ .map(d => ({ name: d.name, ts: parseInt(d.name.split('_')[1]) || 0 }))
27
+ .sort((a, b) => b.ts - a.ts);
28
+
29
+ if (txDirs.length > maxToKeep) {
30
+ for (const dir of txDirs.slice(maxToKeep)) {
31
+ await fs.rm(path.join(this.snapshotDir, dir.name), { recursive: true, force: true });
32
+ }
33
+ }
34
+ } catch (e) {
35
+ // Ignore pruning errors
36
+ }
37
+ }
38
+
19
39
  generateTransactionId() {
20
40
  return `tx_${Date.now()}_${crypto.randomBytes(4).toString('hex')}`;
21
41
  }
@@ -57,7 +77,7 @@ export class SnapshotManager {
57
77
  }
58
78
 
59
79
  if (isFile) {
60
- const localSnapshotPath = path.join(txDir, crypto.createHash('md5').update(remotePath).digest('hex'));
80
+ const localSnapshotPath = path.join(txDir, crypto.createHash('sha256').update(remotePath).digest('hex'));
61
81
 
62
82
  if (useSFTP) {
63
83
  await client.get(remotePath, localSnapshotPath);
@@ -87,6 +107,9 @@ export class SnapshotManager {
87
107
  'utf8'
88
108
  );
89
109
 
110
+ // Auto-prune old snapshots to prevent unbounded disk growth
111
+ await this.pruneOldSnapshots();
112
+
90
113
  return txId;
91
114
  }
92
115
 
package/sync-manifest.js CHANGED
@@ -28,7 +28,7 @@ export class SyncManifestManager {
28
28
  async getFileHash(filePath) {
29
29
  try {
30
30
  const content = await fs.readFile(filePath);
31
- return crypto.createHash('md5').update(content).digest('hex');
31
+ return crypto.createHash('sha256').update(content).digest('hex');
32
32
  } catch (e) {
33
33
  return null;
34
34
  }