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 +232 -46
- package/package.json +1 -1
- package/snapshot-manager.js +24 -1
- package/sync-manifest.js +1 -1
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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:
|
|
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
|
-
|
|
990
|
-
|
|
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
|
-
|
|
1251
|
-
currentProfile
|
|
1252
|
-
|
|
1253
|
-
const
|
|
1254
|
-
const
|
|
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: ${
|
|
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(
|
|
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(
|
|
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
|
-
|
|
1562
|
-
const
|
|
1563
|
-
const
|
|
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: ${
|
|
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(
|
|
1681
|
+
await client.stat(filePath);
|
|
1591
1682
|
exists = true;
|
|
1592
1683
|
} else {
|
|
1593
|
-
|
|
1594
|
-
const
|
|
1595
|
-
const
|
|
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 (
|
|
1648
|
-
|
|
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 (
|
|
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 (
|
|
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;
|
|
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
|
|
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: ${
|
|
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
|
|
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: ${
|
|
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(
|
|
2167
|
+
await client.rmdir(rmPath, recursive);
|
|
2011
2168
|
} else {
|
|
2012
2169
|
if (recursive) {
|
|
2013
|
-
await client.removeDir(
|
|
2170
|
+
await client.removeDir(rmPath);
|
|
2014
2171
|
} else {
|
|
2015
|
-
await client.
|
|
2172
|
+
await client.removeEmptyDir(rmPath);
|
|
2016
2173
|
}
|
|
2017
2174
|
}
|
|
2018
2175
|
|
|
2019
2176
|
return {
|
|
2020
|
-
content: [{ type: "text", text: `Successfully removed directory ${
|
|
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
|
-
|
|
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 ${
|
|
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.
|
|
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",
|
package/snapshot-manager.js
CHANGED
|
@@ -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('
|
|
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('
|
|
31
|
+
return crypto.createHash('sha256').update(content).digest('hex');
|
|
32
32
|
} catch (e) {
|
|
33
33
|
return null;
|
|
34
34
|
}
|