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