ftp-mcp 1.2.0 → 1.2.2
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 +5 -1
- package/index.js +22 -22
- package/package.json +1 -1
- package/policy-engine.js +6 -1
package/README.md
CHANGED
|
@@ -86,7 +86,7 @@ export FTPMCP_AGENT="pageant"
|
|
|
86
86
|
|
|
87
87
|
### 3. Client Integration
|
|
88
88
|
|
|
89
|
-
Register the MCP server directly to your AI Code Editor (Cursor,
|
|
89
|
+
Register the MCP server directly to your AI Code Editor (Cursor, Cline, Claude Desktop, Windsurf, etc.).
|
|
90
90
|
|
|
91
91
|
Example integration:
|
|
92
92
|
- **Command:** `npx`
|
|
@@ -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
|
@@ -27,7 +27,7 @@ const CONFIG_FILE = process.env.FTP_CONFIG_PATH || path.join(process.cwd(), DEFA
|
|
|
27
27
|
// --init: scaffold .ftpconfig.example into the user's current working directory
|
|
28
28
|
if (process.argv.includes("--init")) {
|
|
29
29
|
try {
|
|
30
|
-
const { intro, outro, text, password: promptPassword, select, confirm, note } = await import("@clack/prompts");
|
|
30
|
+
const { intro, outro, text, password: promptPassword, select, confirm, note, isCancel } = await import("@clack/prompts");
|
|
31
31
|
|
|
32
32
|
intro('🚀 Welcome to FTP-MCP Initialization Wizard');
|
|
33
33
|
|
|
@@ -36,30 +36,35 @@ if (process.argv.includes("--init")) {
|
|
|
36
36
|
placeholder: 'sftp://127.0.0.1',
|
|
37
37
|
validate: (val) => val.length === 0 ? "Host is required!" : undefined,
|
|
38
38
|
});
|
|
39
|
+
if (isCancel(host)) { outro('Setup cancelled.'); process.exit(0); }
|
|
39
40
|
|
|
40
41
|
const user = await text({
|
|
41
42
|
message: 'Enter your Username',
|
|
42
43
|
validate: (val) => val.length === 0 ? "User is required!" : undefined,
|
|
43
44
|
});
|
|
45
|
+
if (isCancel(user)) { outro('Setup cancelled.'); process.exit(0); }
|
|
44
46
|
|
|
45
47
|
const pass = await promptPassword({
|
|
46
48
|
message: 'Enter your Password (optional if using keys)',
|
|
47
49
|
});
|
|
50
|
+
if (isCancel(pass)) { outro('Setup cancelled.'); process.exit(0); }
|
|
48
51
|
|
|
49
52
|
const port = await text({
|
|
50
53
|
message: 'Enter port (optional, defaults to 21 for FTP, 22 for SFTP)',
|
|
51
54
|
placeholder: '22'
|
|
52
55
|
});
|
|
56
|
+
if (isCancel(port)) { outro('Setup cancelled.'); process.exit(0); }
|
|
53
57
|
|
|
54
|
-
const isSFTP = host.startsWith('sftp://');
|
|
58
|
+
const isSFTP = typeof host === 'string' && host.startsWith('sftp://');
|
|
55
59
|
let privateKey = '';
|
|
56
60
|
|
|
57
61
|
if (isSFTP) {
|
|
58
62
|
const usesKey = await confirm({ message: 'Are you using an SSH Private Key instead of a password?' });
|
|
59
|
-
if (usesKey) {
|
|
63
|
+
if (!isCancel(usesKey) && usesKey) {
|
|
60
64
|
privateKey = await text({
|
|
61
65
|
message: 'Path to your private key (e.g. ~/.ssh/id_rsa)',
|
|
62
66
|
});
|
|
67
|
+
if (isCancel(privateKey)) { outro('Setup cancelled.'); process.exit(0); }
|
|
63
68
|
}
|
|
64
69
|
}
|
|
65
70
|
|
|
@@ -450,7 +455,7 @@ function releaseClient(config) {
|
|
|
450
455
|
async function getTreeRecursive(client, useSFTP, remotePath, depth = 0, maxDepth = 10) {
|
|
451
456
|
if (depth > maxDepth) return [];
|
|
452
457
|
|
|
453
|
-
const files =
|
|
458
|
+
const files = await client.list(remotePath);
|
|
454
459
|
const results = [];
|
|
455
460
|
|
|
456
461
|
for (const file of files) {
|
|
@@ -475,12 +480,13 @@ async function getTreeRecursive(client, useSFTP, remotePath, depth = 0, maxDepth
|
|
|
475
480
|
return results;
|
|
476
481
|
}
|
|
477
482
|
|
|
478
|
-
async function syncFiles(client, useSFTP, localPath, remotePath, direction, ignorePatterns = null, basePath = null, extraExclude = [], dryRun = false, useManifest = true) {
|
|
483
|
+
async function syncFiles(client, useSFTP, localPath, remotePath, direction, ignorePatterns = null, basePath = null, extraExclude = [], dryRun = false, useManifest = true, _isTopLevel = false) {
|
|
479
484
|
const stats = { uploaded: 0, downloaded: 0, skipped: 0, errors: [], ignored: 0, filesToChange: [] };
|
|
480
485
|
|
|
481
486
|
if (ignorePatterns === null) {
|
|
482
487
|
ignorePatterns = await loadIgnorePatterns(localPath);
|
|
483
488
|
basePath = localPath;
|
|
489
|
+
_isTopLevel = true;
|
|
484
490
|
if (useManifest) await syncManifestManager.load();
|
|
485
491
|
}
|
|
486
492
|
|
|
@@ -496,8 +502,8 @@ async function syncFiles(client, useSFTP, localPath, remotePath, direction, igno
|
|
|
496
502
|
const remoteFilePath = `${remotePath}/${file.name}`;
|
|
497
503
|
|
|
498
504
|
// In some environments (like Windows with ftp-srv), rapid transfers cause ECONNRESET.
|
|
499
|
-
// A
|
|
500
|
-
await new Promise(r => setTimeout(r,
|
|
505
|
+
// A short delay helps stabilize the socket state during sequence (FTP only).
|
|
506
|
+
if (!useSFTP) await new Promise(r => setTimeout(r, 50));
|
|
501
507
|
|
|
502
508
|
// Security check first so we can warn even if it's in .gitignore/.ftpignore
|
|
503
509
|
if (isSecretFile(localFilePath)) {
|
|
@@ -602,7 +608,7 @@ async function syncFiles(client, useSFTP, localPath, remotePath, direction, igno
|
|
|
602
608
|
}
|
|
603
609
|
}
|
|
604
610
|
|
|
605
|
-
if (
|
|
611
|
+
if (_isTopLevel && useManifest && !dryRun) {
|
|
606
612
|
await syncManifestManager.save();
|
|
607
613
|
}
|
|
608
614
|
|
|
@@ -657,7 +663,7 @@ function generateSemanticPreview(filesToChange) {
|
|
|
657
663
|
const server = new Server(
|
|
658
664
|
{
|
|
659
665
|
name: "ftp-mcp-server",
|
|
660
|
-
version: "1.2.
|
|
666
|
+
version: "1.2.1",
|
|
661
667
|
},
|
|
662
668
|
{
|
|
663
669
|
capabilities: {
|
|
@@ -1182,8 +1188,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
1182
1188
|
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
1183
1189
|
if (request.params.name === "ftp_list_deployments") {
|
|
1184
1190
|
try {
|
|
1185
|
-
const
|
|
1186
|
-
const configData = await fs.readFile(configPath, 'utf8');
|
|
1191
|
+
const configData = await fs.readFile(CONFIG_FILE, 'utf8');
|
|
1187
1192
|
const config = JSON.parse(configData);
|
|
1188
1193
|
|
|
1189
1194
|
if (!config.deployments || Object.keys(config.deployments).length === 0) {
|
|
@@ -1216,8 +1221,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1216
1221
|
if (request.params.name === "ftp_deploy") {
|
|
1217
1222
|
try {
|
|
1218
1223
|
const { deployment } = request.params.arguments;
|
|
1219
|
-
const
|
|
1220
|
-
const configData = await fs.readFile(configPath, 'utf8');
|
|
1224
|
+
const configData = await fs.readFile(CONFIG_FILE, 'utf8');
|
|
1221
1225
|
const config = JSON.parse(configData);
|
|
1222
1226
|
|
|
1223
1227
|
if (!config.deployments || !config.deployments[deployment]) {
|
|
@@ -1376,7 +1380,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1376
1380
|
|
|
1377
1381
|
let files = getCached(poolKey, 'LIST', path);
|
|
1378
1382
|
if (!files) {
|
|
1379
|
-
files =
|
|
1383
|
+
files = await client.list(path);
|
|
1380
1384
|
files.sort((a, b) => a.name.localeCompare(b.name));
|
|
1381
1385
|
setCached(poolKey, 'LIST', path, files);
|
|
1382
1386
|
}
|
|
@@ -1655,7 +1659,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1655
1659
|
let formatted = "";
|
|
1656
1660
|
|
|
1657
1661
|
if (contentPattern) {
|
|
1658
|
-
const contentRegex = new RegExp(contentPattern, '
|
|
1662
|
+
const contentRegex = new RegExp(contentPattern, 'i');
|
|
1659
1663
|
const contentMatches = [];
|
|
1660
1664
|
|
|
1661
1665
|
for (const item of sliced) {
|
|
@@ -1703,7 +1707,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1703
1707
|
const path = request.params.arguments?.path || ".";
|
|
1704
1708
|
let files = getCached(poolKey, 'LIST', path);
|
|
1705
1709
|
if (!files) {
|
|
1706
|
-
files =
|
|
1710
|
+
files = await client.list(path);
|
|
1707
1711
|
setCached(poolKey, 'LIST', path, files);
|
|
1708
1712
|
}
|
|
1709
1713
|
|
|
@@ -2008,7 +2012,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
2008
2012
|
if (recursive) {
|
|
2009
2013
|
await client.removeDir(path);
|
|
2010
2014
|
} else {
|
|
2011
|
-
await client.
|
|
2015
|
+
await client.send("RMD", path);
|
|
2012
2016
|
}
|
|
2013
2017
|
}
|
|
2014
2018
|
|
|
@@ -2045,11 +2049,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
2045
2049
|
|
|
2046
2050
|
const txId = await snapshotManager.createSnapshot(client, useSFTP, [oldPath, newPath]);
|
|
2047
2051
|
|
|
2048
|
-
|
|
2049
|
-
await client.rename(oldPath, newPath);
|
|
2050
|
-
} else {
|
|
2051
|
-
await client.rename(oldPath, newPath);
|
|
2052
|
-
}
|
|
2052
|
+
await client.rename(oldPath, newPath);
|
|
2053
2053
|
|
|
2054
2054
|
return {
|
|
2055
2055
|
content: [{ type: "text", text: `Successfully renamed ${oldPath} to ${newPath}\nTransaction ID: ${txId}` }]
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ftp-mcp",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.2",
|
|
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) {
|