ftp-mcp 1.5.2 → 1.5.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/index.js +101 -69
- package/package.json +1 -1
package/index.js
CHANGED
|
@@ -313,6 +313,45 @@ function assertSafeRemotePath(remotePath) {
|
|
|
313
313
|
}
|
|
314
314
|
}
|
|
315
315
|
|
|
316
|
+
/**
|
|
317
|
+
* Ensures that a specific remote directory exists.
|
|
318
|
+
* For standard FTP, this utility also handles the crucial reset of the
|
|
319
|
+
* Current Working Directory (CWD) to prevent implicit state leakage.
|
|
320
|
+
*/
|
|
321
|
+
async function safeMkdir(client, useSFTP, remotePath) {
|
|
322
|
+
if (!remotePath || remotePath === '.' || remotePath === '/') return;
|
|
323
|
+
|
|
324
|
+
if (useSFTP) {
|
|
325
|
+
// SFTP mkdir with recursive: true
|
|
326
|
+
await client.mkdir(remotePath, true).catch(() => {});
|
|
327
|
+
} else {
|
|
328
|
+
// FTP ensureDir switches CWD; we MUST reset it.
|
|
329
|
+
try {
|
|
330
|
+
await client.ensureDir(remotePath);
|
|
331
|
+
if (client._initialPwd) {
|
|
332
|
+
await client.cd(client._initialPwd).catch(() => {});
|
|
333
|
+
}
|
|
334
|
+
} catch (e) {
|
|
335
|
+
// Some servers might error if directory already exists or permissions are weird
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Ensures that the parent directory structure of a remote file path exists.
|
|
342
|
+
*/
|
|
343
|
+
async function ensureRemoteDir(client, useSFTP, remotePath) {
|
|
344
|
+
// remotePath could be a filename or a path like "dir/subdir/file.js"
|
|
345
|
+
// We extract the directory portion using forward-slash splitting.
|
|
346
|
+
const parts = remotePath.split('/');
|
|
347
|
+
if (parts.length <= 1) return; // Root or simple filename, nothing to ensure
|
|
348
|
+
|
|
349
|
+
const dirName = parts.slice(0, -1).join('/');
|
|
350
|
+
if (!dirName || dirName === '.' || dirName === '/') return;
|
|
351
|
+
|
|
352
|
+
await safeMkdir(client, useSFTP, dirName);
|
|
353
|
+
}
|
|
354
|
+
|
|
316
355
|
function shouldIgnore(filePath, ignorePatterns, basePath) {
|
|
317
356
|
const relativePath = path.relative(basePath, filePath).replace(/\\/g, '/');
|
|
318
357
|
|
|
@@ -413,7 +452,7 @@ function isSFTP(host) {
|
|
|
413
452
|
async function connectFTP(config) {
|
|
414
453
|
const client = new FTPClient();
|
|
415
454
|
client.ftp.verbose = false;
|
|
416
|
-
|
|
455
|
+
|
|
417
456
|
if (config.verbose) {
|
|
418
457
|
client.ftp.log = (msg) => {
|
|
419
458
|
server.sendLoggingMessage({
|
|
@@ -432,7 +471,7 @@ async function connectFTP(config) {
|
|
|
432
471
|
secureOptions: config.secureOptions || { rejectUnauthorized: false }, // Default to false for easy dev if not specified
|
|
433
472
|
timeout: config.timeout || 30000 // 30s connection timeout
|
|
434
473
|
});
|
|
435
|
-
|
|
474
|
+
|
|
436
475
|
try {
|
|
437
476
|
client._initialPwd = await client.pwd();
|
|
438
477
|
} catch (e) {
|
|
@@ -535,7 +574,7 @@ async function getClient(config) {
|
|
|
535
574
|
telemetry.activeConnections++;
|
|
536
575
|
|
|
537
576
|
client._isSFTP = useSFTP;
|
|
538
|
-
|
|
577
|
+
|
|
539
578
|
// Silence MaxListenersExceededWarning during high-activity syncs/sessions
|
|
540
579
|
if (typeof client.setMaxListeners === 'function') {
|
|
541
580
|
client.setMaxListeners(100);
|
|
@@ -550,7 +589,7 @@ async function getClient(config) {
|
|
|
550
589
|
async execute(task) {
|
|
551
590
|
// Use a simple promise chain to serialize operations on this client
|
|
552
591
|
const result = this.promiseQueue.then(() => task(this.client));
|
|
553
|
-
this.promiseQueue = result.catch(() => {}); // Continue queue even on error
|
|
592
|
+
this.promiseQueue = result.catch(() => { }); // Continue queue even on error
|
|
554
593
|
return result;
|
|
555
594
|
}
|
|
556
595
|
};
|
|
@@ -684,29 +723,30 @@ async function syncFiles(client, useSFTP, localPath, remotePath, direction, igno
|
|
|
684
723
|
// --- ANTI-RECURSION GUARD ---
|
|
685
724
|
// If we're syncing the current directory (.), and the remote target is a local folder (e.g. 'FlowdexMCP_LiveTest'),
|
|
686
725
|
// we MUST ignore the remote target folder locally so we don't upload it into itself forever.
|
|
726
|
+
// This caused more issues than I'm willing to admit.
|
|
687
727
|
const resolvedLocal = path.resolve(localPath);
|
|
688
728
|
// Rough check: if remotePath is just a folder name (no slashes) or a relative path, ignore it
|
|
689
729
|
if (remotePath && typeof remotePath === 'string' && !remotePath.startsWith('/')) {
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
// Re-instantiate the Ignore instance since we appended pattern strings
|
|
696
|
-
if (ignorePatterns._ig) delete ignorePatterns._ig;
|
|
730
|
+
const firstFolder = remotePath.split('/')[0];
|
|
731
|
+
if (firstFolder && firstFolder !== '.') {
|
|
732
|
+
ignorePatterns.push(firstFolder);
|
|
733
|
+
ignorePatterns.push(`${firstFolder}/**`);
|
|
697
734
|
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
735
|
+
// Re-instantiate the Ignore instance since we appended pattern strings
|
|
736
|
+
if (ignorePatterns._ig) delete ignorePatterns._ig;
|
|
737
|
+
|
|
738
|
+
server.notification({
|
|
739
|
+
method: "notifications/message",
|
|
740
|
+
params: {
|
|
741
|
+
level: "info",
|
|
742
|
+
data: { message: `[SYNC GUARD] Excluded target '${firstFolder}' from local sync to prevent infinite recursion.` }
|
|
743
|
+
}
|
|
744
|
+
});
|
|
745
|
+
}
|
|
706
746
|
}
|
|
707
747
|
|
|
708
748
|
if (useManifest) await syncManifestManager.load();
|
|
709
|
-
|
|
749
|
+
|
|
710
750
|
// Initialize progress tracking if a token is provided
|
|
711
751
|
if (progressState && progressState.token) {
|
|
712
752
|
server.notification({
|
|
@@ -749,12 +789,7 @@ async function syncFiles(client, useSFTP, localPath, remotePath, direction, igno
|
|
|
749
789
|
try {
|
|
750
790
|
if (file.isDirectory()) {
|
|
751
791
|
if (!dryRun) {
|
|
752
|
-
|
|
753
|
-
await client.mkdir(remoteFilePath, true);
|
|
754
|
-
} else {
|
|
755
|
-
await client.ensureDir(remoteFilePath);
|
|
756
|
-
if (client._initialPwd) await client.cd(client._initialPwd).catch(() => {});
|
|
757
|
-
}
|
|
792
|
+
await safeMkdir(client, useSFTP, remoteFilePath);
|
|
758
793
|
}
|
|
759
794
|
const subStats = await syncFiles(client, useSFTP, localFilePath, remoteFilePath, direction, ignorePatterns, basePath, extraExclude, dryRun, useManifest, false, progressState);
|
|
760
795
|
stats.uploaded += subStats.uploaded;
|
|
@@ -778,15 +813,15 @@ async function syncFiles(client, useSFTP, localPath, remotePath, direction, igno
|
|
|
778
813
|
try {
|
|
779
814
|
let remoteStat = undefined;
|
|
780
815
|
if (useSFTP) {
|
|
781
|
-
|
|
816
|
+
remoteStat = await client.stat(remoteFilePath).catch(() => undefined);
|
|
782
817
|
} else {
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
818
|
+
// To avoid 1,000 MLSD calls, we fetch the directory list once if not cached
|
|
819
|
+
let dirList = getCached(`sync-${client.ftp?.host || 'local'}`, 'LIST', remotePath);
|
|
820
|
+
if (!dirList) {
|
|
821
|
+
dirList = await client.list(remotePath).catch(() => []);
|
|
822
|
+
setCached(`sync-${client.ftp?.host || 'local'}`, 'LIST', remotePath, dirList);
|
|
823
|
+
}
|
|
824
|
+
remoteStat = dirList.find(f => f.name === file.name);
|
|
790
825
|
}
|
|
791
826
|
|
|
792
827
|
if (remoteStat) {
|
|
@@ -1455,7 +1490,7 @@ server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
|
|
|
1455
1490
|
if (request.params.uri === "mcp://instruction/server-state") {
|
|
1456
1491
|
const isReadOnly = currentConfig?.readOnly || false;
|
|
1457
1492
|
const protocol = currentConfig?.host?.startsWith('sftp://') ? 'SFTP' : 'FTP';
|
|
1458
|
-
|
|
1493
|
+
|
|
1459
1494
|
const content = `# ftp-mcp Server Context (Agent Guide)
|
|
1460
1495
|
**Current Version:** ${SERVER_VERSION}
|
|
1461
1496
|
**Active Profile:** ${currentProfile || 'Environment Variables'}
|
|
@@ -1498,7 +1533,7 @@ server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
|
|
|
1498
1533
|
} catch (e) {
|
|
1499
1534
|
throw new Error(`Policy Violation: ${e.message}`);
|
|
1500
1535
|
}
|
|
1501
|
-
|
|
1536
|
+
|
|
1502
1537
|
try {
|
|
1503
1538
|
const entry = await getClient(currentConfig);
|
|
1504
1539
|
const content = await entry.execute(async (client) => {
|
|
@@ -1822,28 +1857,28 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1822
1857
|
|
|
1823
1858
|
try {
|
|
1824
1859
|
const entry = await getClient(currentConfig);
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
1860
|
+
const client = entry.client;
|
|
1861
|
+
const useSFTP = client._isSFTP;
|
|
1862
|
+
const poolKey = getPoolKey(currentConfig);
|
|
1863
|
+
const cmdName = request.params.name;
|
|
1864
|
+
const isDestructive = ["ftp_deploy", "ftp_put_contents", "ftp_batch_upload", "ftp_sync", "ftp_upload", "ftp_delete", "ftp_mkdir", "ftp_rmdir", "ftp_chmod", "ftp_rename", "ftp_copy", "ftp_patch_file"].includes(cmdName);
|
|
1865
|
+
|
|
1866
|
+
const policyEngine = new PolicyEngine(currentConfig || {});
|
|
1867
|
+
|
|
1868
|
+
if (isDestructive) {
|
|
1869
|
+
if (currentConfig.readOnly) {
|
|
1870
|
+
const errorResp = {
|
|
1871
|
+
content: [{ type: "text", text: `Error: Profile '${currentProfile}' is configured in readOnly mode. Destructive actions are disabled.` }],
|
|
1872
|
+
isError: true
|
|
1873
|
+
};
|
|
1874
|
+
await auditLog(cmdName, request.params.arguments, 'failed', currentProfile, 'readOnly mode violation');
|
|
1875
|
+
return errorResp;
|
|
1876
|
+
}
|
|
1877
|
+
invalidatePoolCache(poolKey);
|
|
1841
1878
|
}
|
|
1842
|
-
invalidatePoolCache(poolKey);
|
|
1843
|
-
}
|
|
1844
1879
|
|
|
1845
|
-
|
|
1846
|
-
|
|
1880
|
+
const response = await entry.execute(async (client) => {
|
|
1881
|
+
switch (cmdName) {
|
|
1847
1882
|
case "ftp_list": {
|
|
1848
1883
|
const path = request.params.arguments?.path || ".";
|
|
1849
1884
|
const limit = request.params.arguments?.limit || 100;
|
|
@@ -1863,7 +1898,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1863
1898
|
const isDir = (useSFTP ? f.type === 'd' : f.isDirectory);
|
|
1864
1899
|
const icon = isDir ? ICON.DIR : ICON.FILE;
|
|
1865
1900
|
const label = isDir ? '[DIR] ' : '[FILE]';
|
|
1866
|
-
|
|
1901
|
+
|
|
1867
1902
|
let marker = "";
|
|
1868
1903
|
const nameLower = f.name.toLowerCase();
|
|
1869
1904
|
if (['package.json', 'composer.json', 'requirements.txt', 'pyproject.toml', 'go.mod'].includes(nameLower)) marker = ` ${ICON.PKG}`;
|
|
@@ -1976,6 +2011,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1976
2011
|
|
|
1977
2012
|
if (createBackup) {
|
|
1978
2013
|
const backupPath = `${filePath}.bak`;
|
|
2014
|
+
await ensureRemoteDir(client, useSFTP, backupPath);
|
|
1979
2015
|
if (useSFTP) {
|
|
1980
2016
|
const buffer = Buffer.from(content, 'utf8');
|
|
1981
2017
|
await client.put(buffer, backupPath);
|
|
@@ -1985,6 +2021,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1985
2021
|
}
|
|
1986
2022
|
}
|
|
1987
2023
|
|
|
2024
|
+
await ensureRemoteDir(client, useSFTP, filePath);
|
|
1988
2025
|
if (useSFTP) {
|
|
1989
2026
|
const buffer = Buffer.from(patchedContent, 'utf8');
|
|
1990
2027
|
await client.put(buffer, filePath);
|
|
@@ -2009,6 +2046,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
2009
2046
|
|
|
2010
2047
|
const txId = await snapshotManager.createSnapshot(client, useSFTP, [filePath]);
|
|
2011
2048
|
|
|
2049
|
+
await ensureRemoteDir(client, useSFTP, filePath);
|
|
2050
|
+
|
|
2012
2051
|
if (useSFTP) {
|
|
2013
2052
|
const buffer = Buffer.from(content, 'utf8');
|
|
2014
2053
|
await client.put(buffer, filePath);
|
|
@@ -2359,6 +2398,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
2359
2398
|
continue;
|
|
2360
2399
|
}
|
|
2361
2400
|
try {
|
|
2401
|
+
await ensureRemoteDir(client, useSFTP, file.remotePath);
|
|
2362
2402
|
if (useSFTP) {
|
|
2363
2403
|
await client.put(file.localPath, file.remotePath);
|
|
2364
2404
|
} else {
|
|
@@ -2543,15 +2583,11 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
2543
2583
|
|
|
2544
2584
|
const txIdUpload = await snapshotManager.createSnapshot(client, useSFTP, [remotePath]);
|
|
2545
2585
|
|
|
2586
|
+
await ensureRemoteDir(client, useSFTP, remotePath);
|
|
2587
|
+
|
|
2546
2588
|
if (useSFTP) {
|
|
2547
|
-
await client.mkdir(path.dirname(remotePath), true).catch(() => {});
|
|
2548
2589
|
await client.put(localPath, remotePath);
|
|
2549
2590
|
} else {
|
|
2550
|
-
const dirName = remotePath.split('/').slice(0, -1).join('/');
|
|
2551
|
-
if (dirName && dirName !== '.') {
|
|
2552
|
-
await client.ensureDir(dirName).catch(() => {});
|
|
2553
|
-
await client.cd(client._initialPwd || '/').catch(() => {}); // reset to root
|
|
2554
|
-
}
|
|
2555
2591
|
await client.uploadFrom(localPath, remotePath);
|
|
2556
2592
|
}
|
|
2557
2593
|
|
|
@@ -2609,12 +2645,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
2609
2645
|
case "ftp_mkdir": {
|
|
2610
2646
|
const { path } = request.params.arguments;
|
|
2611
2647
|
|
|
2612
|
-
|
|
2613
|
-
await client.mkdir(path, true);
|
|
2614
|
-
} else {
|
|
2615
|
-
await client.ensureDir(path);
|
|
2616
|
-
if (client._initialPwd) await client.cd(client._initialPwd).catch(() => {});
|
|
2617
|
-
}
|
|
2648
|
+
await safeMkdir(client, useSFTP, path);
|
|
2618
2649
|
|
|
2619
2650
|
return {
|
|
2620
2651
|
content: [{ type: "text", text: `Successfully created directory ${path}` }]
|
|
@@ -2681,6 +2712,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
2681
2712
|
|
|
2682
2713
|
const txId = await snapshotManager.createSnapshot(client, useSFTP, [oldPath, newPath]);
|
|
2683
2714
|
|
|
2715
|
+
await ensureRemoteDir(client, useSFTP, newPath);
|
|
2684
2716
|
await client.rename(oldPath, newPath);
|
|
2685
2717
|
|
|
2686
2718
|
return {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ftp-mcp",
|
|
3
|
-
"version": "1.5.
|
|
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",
|