ftp-mcp 1.2.1 → 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/README.md +4 -0
- package/index.js +251 -65
- package/package.json +1 -1
- package/policy-engine.js +6 -1
- package/snapshot-manager.js +24 -1
- package/sync-manifest.js +1 -1
package/README.md
CHANGED
|
@@ -151,6 +151,10 @@ Deploy environments utilizing deep comparison trees via `ftp_sync`.
|
|
|
151
151
|
- Audit Logging is continuously enabled. Ensure the terminal running the MCP protocol maintains structural write-permissions to the local project deployment folder.
|
|
152
152
|
- Ensure any `currentConfig` caching resets are tied exclusively to `ftp_connect` profile pivoting.
|
|
153
153
|
|
|
154
|
+
## Changelog
|
|
155
|
+
|
|
156
|
+
See [CHANGELOG.md](CHANGELOG.md) for a detailed list of changes in each release.
|
|
157
|
+
|
|
154
158
|
## License
|
|
155
159
|
|
|
156
160
|
[MIT](LICENSE)
|
package/index.js
CHANGED
|
@@ -21,13 +21,24 @@ 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
|
|
|
27
38
|
// --init: scaffold .ftpconfig.example into the user's current working directory
|
|
28
39
|
if (process.argv.includes("--init")) {
|
|
29
40
|
try {
|
|
30
|
-
const { intro, outro, text, password: promptPassword, select, confirm, note } = await import("@clack/prompts");
|
|
41
|
+
const { intro, outro, text, password: promptPassword, select, confirm, note, isCancel } = await import("@clack/prompts");
|
|
31
42
|
|
|
32
43
|
intro('🚀 Welcome to FTP-MCP Initialization Wizard');
|
|
33
44
|
|
|
@@ -36,30 +47,35 @@ if (process.argv.includes("--init")) {
|
|
|
36
47
|
placeholder: 'sftp://127.0.0.1',
|
|
37
48
|
validate: (val) => val.length === 0 ? "Host is required!" : undefined,
|
|
38
49
|
});
|
|
50
|
+
if (isCancel(host)) { outro('Setup cancelled.'); process.exit(0); }
|
|
39
51
|
|
|
40
52
|
const user = await text({
|
|
41
53
|
message: 'Enter your Username',
|
|
42
54
|
validate: (val) => val.length === 0 ? "User is required!" : undefined,
|
|
43
55
|
});
|
|
56
|
+
if (isCancel(user)) { outro('Setup cancelled.'); process.exit(0); }
|
|
44
57
|
|
|
45
58
|
const pass = await promptPassword({
|
|
46
59
|
message: 'Enter your Password (optional if using keys)',
|
|
47
60
|
});
|
|
61
|
+
if (isCancel(pass)) { outro('Setup cancelled.'); process.exit(0); }
|
|
48
62
|
|
|
49
63
|
const port = await text({
|
|
50
64
|
message: 'Enter port (optional, defaults to 21 for FTP, 22 for SFTP)',
|
|
51
65
|
placeholder: '22'
|
|
52
66
|
});
|
|
67
|
+
if (isCancel(port)) { outro('Setup cancelled.'); process.exit(0); }
|
|
53
68
|
|
|
54
|
-
const isSFTP = host.startsWith('sftp://');
|
|
69
|
+
const isSFTP = typeof host === 'string' && host.startsWith('sftp://');
|
|
55
70
|
let privateKey = '';
|
|
56
71
|
|
|
57
72
|
if (isSFTP) {
|
|
58
73
|
const usesKey = await confirm({ message: 'Are you using an SSH Private Key instead of a password?' });
|
|
59
|
-
if (usesKey) {
|
|
74
|
+
if (!isCancel(usesKey) && usesKey) {
|
|
60
75
|
privateKey = await text({
|
|
61
76
|
message: 'Path to your private key (e.g. ~/.ssh/id_rsa)',
|
|
62
77
|
});
|
|
78
|
+
if (isCancel(privateKey)) { outro('Setup cancelled.'); process.exit(0); }
|
|
63
79
|
}
|
|
64
80
|
}
|
|
65
81
|
|
|
@@ -157,9 +173,64 @@ function isSecretFile(filePath) {
|
|
|
157
173
|
name.includes('token');
|
|
158
174
|
}
|
|
159
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
|
+
|
|
160
230
|
function shouldIgnore(filePath, ignorePatterns, basePath) {
|
|
161
231
|
const relativePath = path.relative(basePath, filePath).replace(/\\/g, '/');
|
|
162
232
|
|
|
233
|
+
|
|
163
234
|
if (!ignorePatterns._ig) {
|
|
164
235
|
Object.defineProperty(ignorePatterns, '_ig', {
|
|
165
236
|
value: ignore().add(ignorePatterns),
|
|
@@ -238,13 +309,15 @@ async function loadFTPConfig(profileName = null, forceEnv = false) {
|
|
|
238
309
|
}
|
|
239
310
|
|
|
240
311
|
function getPort(host, configPort) {
|
|
241
|
-
if (configPort) return parseInt(configPort);
|
|
242
|
-
|
|
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;
|
|
243
315
|
return 21;
|
|
244
316
|
}
|
|
245
317
|
|
|
246
318
|
function isSFTP(host) {
|
|
247
|
-
|
|
319
|
+
// LOW-4: Only match the sftp:// protocol prefix
|
|
320
|
+
return !!(host && host.startsWith('sftp://'));
|
|
248
321
|
}
|
|
249
322
|
|
|
250
323
|
async function connectFTP(config) {
|
|
@@ -303,12 +376,19 @@ function getCached(poolKey, type, path) {
|
|
|
303
376
|
telemetry.cacheHits++;
|
|
304
377
|
return entry.data;
|
|
305
378
|
}
|
|
379
|
+
// CODE-3: Evict stale entry while we're here
|
|
380
|
+
if (entry) dirCache.delete(`${poolKey}:${type}:${path}`);
|
|
306
381
|
telemetry.cacheMisses++;
|
|
307
382
|
return null;
|
|
308
383
|
}
|
|
309
384
|
|
|
310
385
|
function setCached(poolKey, type, path, data) {
|
|
311
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
|
+
}
|
|
312
392
|
}
|
|
313
393
|
|
|
314
394
|
function invalidatePoolCache(poolKey) {
|
|
@@ -427,7 +507,8 @@ async function auditLog(toolName, args, status, user, errorMsg = null) {
|
|
|
427
507
|
};
|
|
428
508
|
await fs.appendFile(path.join(process.cwd(), '.ftp-mcp-audit.log'), JSON.stringify(logEntry) + '\n', 'utf8');
|
|
429
509
|
} catch (e) {
|
|
430
|
-
//
|
|
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);
|
|
431
512
|
}
|
|
432
513
|
}
|
|
433
514
|
|
|
@@ -450,7 +531,7 @@ function releaseClient(config) {
|
|
|
450
531
|
async function getTreeRecursive(client, useSFTP, remotePath, depth = 0, maxDepth = 10) {
|
|
451
532
|
if (depth > maxDepth) return [];
|
|
452
533
|
|
|
453
|
-
const files =
|
|
534
|
+
const files = await client.list(remotePath);
|
|
454
535
|
const results = [];
|
|
455
536
|
|
|
456
537
|
for (const file of files) {
|
|
@@ -475,12 +556,13 @@ async function getTreeRecursive(client, useSFTP, remotePath, depth = 0, maxDepth
|
|
|
475
556
|
return results;
|
|
476
557
|
}
|
|
477
558
|
|
|
478
|
-
async function syncFiles(client, useSFTP, localPath, remotePath, direction, ignorePatterns = null, basePath = null, extraExclude = [], dryRun = false, useManifest = true) {
|
|
559
|
+
async function syncFiles(client, useSFTP, localPath, remotePath, direction, ignorePatterns = null, basePath = null, extraExclude = [], dryRun = false, useManifest = true, _isTopLevel = false) {
|
|
479
560
|
const stats = { uploaded: 0, downloaded: 0, skipped: 0, errors: [], ignored: 0, filesToChange: [] };
|
|
480
561
|
|
|
481
562
|
if (ignorePatterns === null) {
|
|
482
563
|
ignorePatterns = await loadIgnorePatterns(localPath);
|
|
483
564
|
basePath = localPath;
|
|
565
|
+
_isTopLevel = true;
|
|
484
566
|
if (useManifest) await syncManifestManager.load();
|
|
485
567
|
}
|
|
486
568
|
|
|
@@ -496,8 +578,8 @@ async function syncFiles(client, useSFTP, localPath, remotePath, direction, igno
|
|
|
496
578
|
const remoteFilePath = `${remotePath}/${file.name}`;
|
|
497
579
|
|
|
498
580
|
// In some environments (like Windows with ftp-srv), rapid transfers cause ECONNRESET.
|
|
499
|
-
// A
|
|
500
|
-
await new Promise(r => setTimeout(r,
|
|
581
|
+
// A short delay helps stabilize the socket state during sequence (FTP only).
|
|
582
|
+
if (!useSFTP) await new Promise(r => setTimeout(r, 50));
|
|
501
583
|
|
|
502
584
|
// Security check first so we can warn even if it's in .gitignore/.ftpignore
|
|
503
585
|
if (isSecretFile(localFilePath)) {
|
|
@@ -602,7 +684,7 @@ async function syncFiles(client, useSFTP, localPath, remotePath, direction, igno
|
|
|
602
684
|
}
|
|
603
685
|
}
|
|
604
686
|
|
|
605
|
-
if (
|
|
687
|
+
if (_isTopLevel && useManifest && !dryRun) {
|
|
606
688
|
await syncManifestManager.save();
|
|
607
689
|
}
|
|
608
690
|
|
|
@@ -657,7 +739,7 @@ function generateSemanticPreview(filesToChange) {
|
|
|
657
739
|
const server = new Server(
|
|
658
740
|
{
|
|
659
741
|
name: "ftp-mcp-server",
|
|
660
|
-
version:
|
|
742
|
+
version: SERVER_VERSION, // CODE-1: reads from package.json at startup
|
|
661
743
|
},
|
|
662
744
|
{
|
|
663
745
|
capabilities: {
|
|
@@ -980,8 +1062,9 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
980
1062
|
},
|
|
981
1063
|
direction: {
|
|
982
1064
|
type: "string",
|
|
983
|
-
|
|
984
|
-
|
|
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"],
|
|
985
1068
|
default: "upload"
|
|
986
1069
|
},
|
|
987
1070
|
dryRun: {
|
|
@@ -1182,8 +1265,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
1182
1265
|
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
1183
1266
|
if (request.params.name === "ftp_list_deployments") {
|
|
1184
1267
|
try {
|
|
1185
|
-
const
|
|
1186
|
-
const configData = await fs.readFile(configPath, 'utf8');
|
|
1268
|
+
const configData = await fs.readFile(CONFIG_FILE, 'utf8');
|
|
1187
1269
|
const config = JSON.parse(configData);
|
|
1188
1270
|
|
|
1189
1271
|
if (!config.deployments || Object.keys(config.deployments).length === 0) {
|
|
@@ -1216,8 +1298,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1216
1298
|
if (request.params.name === "ftp_deploy") {
|
|
1217
1299
|
try {
|
|
1218
1300
|
const { deployment } = request.params.arguments;
|
|
1219
|
-
const
|
|
1220
|
-
const configData = await fs.readFile(configPath, 'utf8');
|
|
1301
|
+
const configData = await fs.readFile(CONFIG_FILE, 'utf8');
|
|
1221
1302
|
const config = JSON.parse(configData);
|
|
1222
1303
|
|
|
1223
1304
|
if (!config.deployments || !config.deployments[deployment]) {
|
|
@@ -1243,16 +1324,17 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1243
1324
|
};
|
|
1244
1325
|
}
|
|
1245
1326
|
|
|
1246
|
-
|
|
1247
|
-
currentProfile
|
|
1248
|
-
|
|
1249
|
-
const
|
|
1250
|
-
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);
|
|
1251
1333
|
|
|
1252
1334
|
try {
|
|
1253
1335
|
const localPath = path.resolve(deployConfig.local);
|
|
1254
1336
|
const stats = await syncFiles(
|
|
1255
|
-
client,
|
|
1337
|
+
deployEntry.client,
|
|
1256
1338
|
useSFTP,
|
|
1257
1339
|
localPath,
|
|
1258
1340
|
deployConfig.remote,
|
|
@@ -1265,11 +1347,11 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1265
1347
|
return {
|
|
1266
1348
|
content: [{
|
|
1267
1349
|
type: "text",
|
|
1268
|
-
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') : ''}`
|
|
1269
1351
|
}]
|
|
1270
1352
|
};
|
|
1271
1353
|
} finally {
|
|
1272
|
-
releaseClient(
|
|
1354
|
+
releaseClient(deployProfileConfig);
|
|
1273
1355
|
}
|
|
1274
1356
|
} catch (error) {
|
|
1275
1357
|
return {
|
|
@@ -1297,6 +1379,18 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1297
1379
|
};
|
|
1298
1380
|
}
|
|
1299
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
|
+
|
|
1300
1394
|
let warning = "";
|
|
1301
1395
|
const isProd = (profile || currentProfile || '').toLowerCase().includes('prod');
|
|
1302
1396
|
if (isProd && !isSFTP(currentConfig.host)) {
|
|
@@ -1376,7 +1470,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1376
1470
|
|
|
1377
1471
|
let files = getCached(poolKey, 'LIST', path);
|
|
1378
1472
|
if (!files) {
|
|
1379
|
-
files =
|
|
1473
|
+
files = await client.list(path);
|
|
1380
1474
|
files.sort((a, b) => a.name.localeCompare(b.name));
|
|
1381
1475
|
setCached(poolKey, 'LIST', path, files);
|
|
1382
1476
|
}
|
|
@@ -1536,10 +1630,10 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1536
1630
|
}
|
|
1537
1631
|
|
|
1538
1632
|
case "ftp_stat": {
|
|
1539
|
-
const { path } = request.params.arguments;
|
|
1633
|
+
const { path: filePath } = request.params.arguments;
|
|
1540
1634
|
|
|
1541
1635
|
if (useSFTP) {
|
|
1542
|
-
const stats = await client.stat(
|
|
1636
|
+
const stats = await client.stat(filePath);
|
|
1543
1637
|
return {
|
|
1544
1638
|
content: [{
|
|
1545
1639
|
type: "text",
|
|
@@ -1554,13 +1648,14 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1554
1648
|
}]
|
|
1555
1649
|
};
|
|
1556
1650
|
} else {
|
|
1557
|
-
|
|
1558
|
-
const
|
|
1559
|
-
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);
|
|
1560
1655
|
const file = files.find(f => f.name === fileName);
|
|
1561
1656
|
|
|
1562
1657
|
if (!file) {
|
|
1563
|
-
throw new Error(`File not found: ${
|
|
1658
|
+
throw new Error(`File not found: ${filePath}`);
|
|
1564
1659
|
}
|
|
1565
1660
|
|
|
1566
1661
|
return {
|
|
@@ -1578,17 +1673,18 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1578
1673
|
}
|
|
1579
1674
|
|
|
1580
1675
|
case "ftp_exists": {
|
|
1581
|
-
const { path } = request.params.arguments;
|
|
1676
|
+
const { path: filePath } = request.params.arguments;
|
|
1582
1677
|
let exists = false;
|
|
1583
1678
|
|
|
1584
1679
|
try {
|
|
1585
1680
|
if (useSFTP) {
|
|
1586
|
-
await client.stat(
|
|
1681
|
+
await client.stat(filePath);
|
|
1587
1682
|
exists = true;
|
|
1588
1683
|
} else {
|
|
1589
|
-
|
|
1590
|
-
const
|
|
1591
|
-
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);
|
|
1592
1688
|
exists = files.some(f => f.name === fileName);
|
|
1593
1689
|
}
|
|
1594
1690
|
} catch (e) {
|
|
@@ -1626,6 +1722,22 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1626
1722
|
return { content: [{ type: "text", text: "Error: Must provide pattern, contentPattern, or findLikelyConfigs" }], isError: true };
|
|
1627
1723
|
}
|
|
1628
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
|
+
|
|
1629
1741
|
const cacheKey = `${searchPath}:10`;
|
|
1630
1742
|
let tree = getCached(poolKey, 'TREE', cacheKey);
|
|
1631
1743
|
if (!tree) {
|
|
@@ -1640,9 +1752,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1640
1752
|
matches = matches.filter(item => configRegex.test(item.name));
|
|
1641
1753
|
}
|
|
1642
1754
|
|
|
1643
|
-
if (
|
|
1644
|
-
|
|
1645
|
-
matches = matches.filter(item => regex.test(item.name));
|
|
1755
|
+
if (compiledPattern) {
|
|
1756
|
+
matches = matches.filter(item => compiledPattern.test(item.name));
|
|
1646
1757
|
}
|
|
1647
1758
|
|
|
1648
1759
|
if (extension) {
|
|
@@ -1654,8 +1765,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1654
1765
|
let sliced = matches.slice(offset, offset + limit);
|
|
1655
1766
|
let formatted = "";
|
|
1656
1767
|
|
|
1657
|
-
if (
|
|
1658
|
-
const contentRegex = new RegExp(contentPattern, 'gi');
|
|
1768
|
+
if (compiledContentPattern) {
|
|
1659
1769
|
const contentMatches = [];
|
|
1660
1770
|
|
|
1661
1771
|
for (const item of sliced) {
|
|
@@ -1674,12 +1784,12 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1674
1784
|
|
|
1675
1785
|
const lines = content.split('\n');
|
|
1676
1786
|
for (let i = 0; i < lines.length; i++) {
|
|
1677
|
-
if (
|
|
1787
|
+
if (compiledContentPattern.test(lines[i])) {
|
|
1678
1788
|
const start = Math.max(0, i - 1);
|
|
1679
1789
|
const end = Math.min(lines.length - 1, i + 1);
|
|
1680
1790
|
const context = lines.slice(start, end + 1).map((l, idx) => `${start + idx + 1}: ${l}`).join('\n');
|
|
1681
1791
|
contentMatches.push(`File: ${item.path}\n${context}\n---`);
|
|
1682
|
-
break;
|
|
1792
|
+
break;
|
|
1683
1793
|
}
|
|
1684
1794
|
}
|
|
1685
1795
|
} catch (e) {
|
|
@@ -1703,7 +1813,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1703
1813
|
const path = request.params.arguments?.path || ".";
|
|
1704
1814
|
let files = getCached(poolKey, 'LIST', path);
|
|
1705
1815
|
if (!files) {
|
|
1706
|
-
files =
|
|
1816
|
+
files = await client.list(path);
|
|
1707
1817
|
setCached(poolKey, 'LIST', path, files);
|
|
1708
1818
|
}
|
|
1709
1819
|
|
|
@@ -1800,6 +1910,14 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1800
1910
|
};
|
|
1801
1911
|
}
|
|
1802
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
|
+
|
|
1803
1921
|
const buffer = await client.get(sourcePath);
|
|
1804
1922
|
await client.put(buffer, destPath);
|
|
1805
1923
|
|
|
@@ -1811,8 +1929,26 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1811
1929
|
case "ftp_batch_upload": {
|
|
1812
1930
|
const { files } = request.params.arguments;
|
|
1813
1931
|
const results = { success: [], failed: [] };
|
|
1932
|
+
const snapshotPaths = files.map(f => f.remotePath);
|
|
1933
|
+
const batchTxId = await snapshotManager.createSnapshot(client, useSFTP, snapshotPaths);
|
|
1814
1934
|
|
|
1815
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
|
+
}
|
|
1816
1952
|
try {
|
|
1817
1953
|
if (useSFTP) {
|
|
1818
1954
|
await client.put(file.localPath, file.remotePath);
|
|
@@ -1828,7 +1964,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1828
1964
|
return {
|
|
1829
1965
|
content: [{
|
|
1830
1966
|
type: "text",
|
|
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') : ''}`
|
|
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') : ''}`
|
|
1832
1968
|
}]
|
|
1833
1969
|
};
|
|
1834
1970
|
}
|
|
@@ -1838,6 +1974,11 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1838
1974
|
const results = { success: [], failed: [] };
|
|
1839
1975
|
|
|
1840
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
|
+
}
|
|
1841
1982
|
try {
|
|
1842
1983
|
if (useSFTP) {
|
|
1843
1984
|
await client.get(file.remotePath, file.localPath);
|
|
@@ -1922,6 +2063,11 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1922
2063
|
case "ftp_upload": {
|
|
1923
2064
|
const { localPath, remotePath } = request.params.arguments;
|
|
1924
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
|
+
|
|
1925
2071
|
if (isSecretFile(localPath)) {
|
|
1926
2072
|
return {
|
|
1927
2073
|
content: [{ type: "text", text: `Security Warning: Blocked upload of likely secret file: ${localPath}` }],
|
|
@@ -1936,7 +2082,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1936
2082
|
return { content: [{ type: "text", text: e.message }], isError: true };
|
|
1937
2083
|
}
|
|
1938
2084
|
|
|
1939
|
-
const
|
|
2085
|
+
const txIdUpload = await snapshotManager.createSnapshot(client, useSFTP, [remotePath]);
|
|
1940
2086
|
|
|
1941
2087
|
if (useSFTP) {
|
|
1942
2088
|
await client.put(localPath, remotePath);
|
|
@@ -1945,13 +2091,18 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1945
2091
|
}
|
|
1946
2092
|
|
|
1947
2093
|
return {
|
|
1948
|
-
content: [{ type: "text", text: `Successfully uploaded ${localPath} to ${remotePath}\nTransaction ID: ${
|
|
2094
|
+
content: [{ type: "text", text: `Successfully uploaded ${localPath} to ${remotePath}\nTransaction ID: ${txIdUpload}` }]
|
|
1949
2095
|
};
|
|
1950
2096
|
}
|
|
1951
2097
|
|
|
1952
2098
|
case "ftp_download": {
|
|
1953
2099
|
const { remotePath, localPath } = request.params.arguments;
|
|
1954
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
|
+
|
|
1955
2106
|
if (useSFTP) {
|
|
1956
2107
|
await client.get(remotePath, localPath);
|
|
1957
2108
|
} else {
|
|
@@ -1966,13 +2117,18 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1966
2117
|
case "ftp_delete": {
|
|
1967
2118
|
const { path: filePath } = request.params.arguments;
|
|
1968
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
|
+
|
|
1969
2125
|
try {
|
|
1970
2126
|
policyEngine.validateOperation('delete', { path: filePath });
|
|
1971
2127
|
} catch (e) {
|
|
1972
2128
|
return { content: [{ type: "text", text: e.message }], isError: true };
|
|
1973
2129
|
}
|
|
1974
2130
|
|
|
1975
|
-
const
|
|
2131
|
+
const txIdDelete = await snapshotManager.createSnapshot(client, useSFTP, [filePath]);
|
|
1976
2132
|
|
|
1977
2133
|
if (useSFTP) {
|
|
1978
2134
|
await client.delete(filePath);
|
|
@@ -1981,7 +2137,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1981
2137
|
}
|
|
1982
2138
|
|
|
1983
2139
|
return {
|
|
1984
|
-
content: [{ type: "text", text: `Successfully deleted ${filePath}\nTransaction ID: ${
|
|
2140
|
+
content: [{ type: "text", text: `Successfully deleted ${filePath}\nTransaction ID: ${txIdDelete}` }]
|
|
1985
2141
|
};
|
|
1986
2142
|
}
|
|
1987
2143
|
|
|
@@ -2000,25 +2156,30 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
2000
2156
|
}
|
|
2001
2157
|
|
|
2002
2158
|
case "ftp_rmdir": {
|
|
2003
|
-
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
|
+
}
|
|
2004
2165
|
|
|
2005
2166
|
if (useSFTP) {
|
|
2006
|
-
await client.rmdir(
|
|
2167
|
+
await client.rmdir(rmPath, recursive);
|
|
2007
2168
|
} else {
|
|
2008
2169
|
if (recursive) {
|
|
2009
|
-
await client.removeDir(
|
|
2170
|
+
await client.removeDir(rmPath);
|
|
2010
2171
|
} else {
|
|
2011
|
-
await client.
|
|
2172
|
+
await client.removeEmptyDir(rmPath);
|
|
2012
2173
|
}
|
|
2013
2174
|
}
|
|
2014
2175
|
|
|
2015
2176
|
return {
|
|
2016
|
-
content: [{ type: "text", text: `Successfully removed directory ${
|
|
2177
|
+
content: [{ type: "text", text: `Successfully removed directory ${rmPath}` }]
|
|
2017
2178
|
};
|
|
2018
2179
|
}
|
|
2019
2180
|
|
|
2020
2181
|
case "ftp_chmod": {
|
|
2021
|
-
const { path, mode } = request.params.arguments;
|
|
2182
|
+
const { path: chmodPath, mode } = request.params.arguments;
|
|
2022
2183
|
|
|
2023
2184
|
if (!useSFTP) {
|
|
2024
2185
|
return {
|
|
@@ -2026,10 +2187,19 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
2026
2187
|
};
|
|
2027
2188
|
}
|
|
2028
2189
|
|
|
2029
|
-
|
|
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);
|
|
2030
2200
|
|
|
2031
2201
|
return {
|
|
2032
|
-
content: [{ type: "text", text: `Successfully changed permissions of ${
|
|
2202
|
+
content: [{ type: "text", text: `Successfully changed permissions of ${chmodPath} to ${mode}` }]
|
|
2033
2203
|
};
|
|
2034
2204
|
}
|
|
2035
2205
|
|
|
@@ -2045,11 +2215,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
2045
2215
|
|
|
2046
2216
|
const txId = await snapshotManager.createSnapshot(client, useSFTP, [oldPath, newPath]);
|
|
2047
2217
|
|
|
2048
|
-
|
|
2049
|
-
await client.rename(oldPath, newPath);
|
|
2050
|
-
} else {
|
|
2051
|
-
await client.rename(oldPath, newPath);
|
|
2052
|
-
}
|
|
2218
|
+
await client.rename(oldPath, newPath);
|
|
2053
2219
|
|
|
2054
2220
|
return {
|
|
2055
2221
|
content: [{ type: "text", text: `Successfully renamed ${oldPath} to ${newPath}\nTransaction ID: ${txId}` }]
|
|
@@ -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/policy-engine.js
CHANGED
|
@@ -10,7 +10,12 @@ export class PolicyEngine {
|
|
|
10
10
|
if (!this.policies.allowedPaths || this.policies.allowedPaths.length === 0) {
|
|
11
11
|
return true; // No restriction
|
|
12
12
|
}
|
|
13
|
-
return this.policies.allowedPaths.some(allowed =>
|
|
13
|
+
return this.policies.allowedPaths.some(allowed => {
|
|
14
|
+
// Normalize: ensure trailing slash to prevent /var/www matching /var/www-evil
|
|
15
|
+
const normalizedAllowed = allowed.endsWith('/') ? allowed : allowed + '/';
|
|
16
|
+
const normalizedPath = filePath.endsWith('/') ? filePath : filePath + '/';
|
|
17
|
+
return normalizedPath.startsWith(normalizedAllowed) || filePath === allowed;
|
|
18
|
+
});
|
|
14
19
|
}
|
|
15
20
|
|
|
16
21
|
checkBlockedGlob(filePath) {
|
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
|
}
|