ftp-mcp 1.4.0 → 1.5.1
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 +33 -0
- package/index.js +265 -12
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -2,6 +2,25 @@
|
|
|
2
2
|
|
|
3
3
|
An enterprise-grade Model Context Protocol (MCP) server providing sophisticated FTP and SFTP operations optimized specifically for AI coding assistants. Features smart synchronization, connection pooling, directory caching, unified diff patching, and comprehensive security controls.
|
|
4
4
|
|
|
5
|
+
## Why this exists
|
|
6
|
+
|
|
7
|
+
Your AI can:
|
|
8
|
+
- spin up a VPS
|
|
9
|
+
- install a web server
|
|
10
|
+
- build your entire app
|
|
11
|
+
|
|
12
|
+
…and then gets stuck trying to upload files.
|
|
13
|
+
|
|
14
|
+
So it writes a Python script.
|
|
15
|
+
Then forgets it exists.
|
|
16
|
+
Then writes it again next session.
|
|
17
|
+
|
|
18
|
+
This fixes that.
|
|
19
|
+
|
|
20
|
+
One connection. One call. Done.
|
|
21
|
+
|
|
22
|
+
Built for AI to install. Designed for AI to use.
|
|
23
|
+
|
|
5
24
|
## Features
|
|
6
25
|
|
|
7
26
|
- **Connection Pooling & Caching**: Sustains underlying connections across tool calls and leverages strict memory caching with smart aggressive invalidation for extreme sub-15ms performance.
|
|
@@ -104,6 +123,20 @@ Instead of requesting a 5,000-line remote file, making a 2-line edit locally, an
|
|
|
104
123
|
### Semantic Workspace Analysis
|
|
105
124
|
Calling `ftp_analyze_workspace` will traverse the remote filesystem natively investigating configurations (`package.json`, `composer.json`). It will evaluate structure patterns and package dependencies, instantly reporting exact context of the remote language mapping (e.g. "Node.js Environment operating React mapping to Express").
|
|
106
125
|
|
|
126
|
+
### Native MCP Prompts
|
|
127
|
+
The server provides built-in prompts that guide AI agents through complex workflows:
|
|
128
|
+
- **`audit-project`**: Instructs the agent on how to perform a deep security and architectural audit of a remote codebase.
|
|
129
|
+
- **`deploy-checklist`**: Provides a standard safety checklist for agents to verify before performing production deployments or massive synchronizations.
|
|
130
|
+
|
|
131
|
+
### Resource Templates (Remote File Access)
|
|
132
|
+
Access remote files as standard MCP resources without explicitly calling a tool:
|
|
133
|
+
- **URI Template**: `mcp://remote-file/{path}`
|
|
134
|
+
- Supports direct UTF-8 reading and respects all `PolicyEngine` security boundaries and path-traversal guards.
|
|
135
|
+
|
|
136
|
+
### Operational Transparency
|
|
137
|
+
- **Real-time Logging**: Emits protocol-native logging notifications for internal events like connection pooling, cache reuse, and transfer lifecycle steps.
|
|
138
|
+
- **Progress Tracking**: Long-running operations (`ftp_sync`, `ftp_batch_upload`, etc.) emit granular progress updates (0-100%) so the AI agent and user can monitor large transfers in real-time.
|
|
139
|
+
|
|
107
140
|
### Strict Safe-Mode Execution
|
|
108
141
|
Set `"readOnly": true` in any `.ftpconfig` profile. AI interactions over `ftp_delete`, `ftp_patch_file`, `ftp_put_contents`, or `ftp_sync(direction: "upload")` will immediately halt with a standardized sandbox violation, keeping production environments entirely safe from hallucinated tool executions.
|
|
109
142
|
|
package/index.js
CHANGED
|
@@ -7,6 +7,9 @@ import {
|
|
|
7
7
|
ListToolsRequestSchema,
|
|
8
8
|
ListResourcesRequestSchema,
|
|
9
9
|
ReadResourceRequestSchema,
|
|
10
|
+
ListResourceTemplatesRequestSchema,
|
|
11
|
+
ListPromptsRequestSchema,
|
|
12
|
+
GetPromptRequestSchema,
|
|
10
13
|
} from "@modelcontextprotocol/sdk/types.js";
|
|
11
14
|
import { Client as FTPClient } from "basic-ftp";
|
|
12
15
|
import SFTPClient from "ssh2-sftp-client";
|
|
@@ -61,7 +64,7 @@ function getAISuggestion(type, context = {}) {
|
|
|
61
64
|
}
|
|
62
65
|
|
|
63
66
|
// Read version from package.json to avoid version drift (CODE-1)
|
|
64
|
-
let SERVER_VERSION = "1.
|
|
67
|
+
let SERVER_VERSION = "1.5.1";
|
|
65
68
|
try {
|
|
66
69
|
const pkg = JSON.parse(readFileSync(path.join(__dirname, 'package.json'), 'utf8'));
|
|
67
70
|
SERVER_VERSION = pkg.version || SERVER_VERSION;
|
|
@@ -494,6 +497,10 @@ async function getClient(config) {
|
|
|
494
497
|
|
|
495
498
|
if (existing && !existing.closed) {
|
|
496
499
|
if (existing.idleTimeout) clearTimeout(existing.idleTimeout);
|
|
500
|
+
server.sendLoggingMessage({
|
|
501
|
+
level: "debug",
|
|
502
|
+
data: { message: `Reusing cached connection for ${poolKey}` }
|
|
503
|
+
});
|
|
497
504
|
return existing;
|
|
498
505
|
}
|
|
499
506
|
|
|
@@ -537,6 +544,10 @@ async function getClient(config) {
|
|
|
537
544
|
}
|
|
538
545
|
|
|
539
546
|
connectionPool.set(poolKey, entry);
|
|
547
|
+
server.sendLoggingMessage({
|
|
548
|
+
level: "info",
|
|
549
|
+
data: { message: `Successfully connected to ${poolKey} (${useSFTP ? 'SFTP' : 'FTP'})` }
|
|
550
|
+
});
|
|
540
551
|
return entry;
|
|
541
552
|
} finally {
|
|
542
553
|
connectingPromises.delete(poolKey);
|
|
@@ -639,7 +650,7 @@ async function getTreeRecursive(client, useSFTP, remotePath, depth = 0, maxDepth
|
|
|
639
650
|
return results;
|
|
640
651
|
}
|
|
641
652
|
|
|
642
|
-
async function syncFiles(client, useSFTP, localPath, remotePath, direction, ignorePatterns = null, basePath = null, extraExclude = [], dryRun = false, useManifest = true, _isTopLevel = false) {
|
|
653
|
+
async function syncFiles(client, useSFTP, localPath, remotePath, direction, ignorePatterns = null, basePath = null, extraExclude = [], dryRun = false, useManifest = true, _isTopLevel = false, progressState = null) {
|
|
643
654
|
const stats = { uploaded: 0, downloaded: 0, skipped: 0, errors: [], ignored: 0, filesToChange: [] };
|
|
644
655
|
|
|
645
656
|
if (ignorePatterns === null) {
|
|
@@ -647,6 +658,19 @@ async function syncFiles(client, useSFTP, localPath, remotePath, direction, igno
|
|
|
647
658
|
basePath = localPath;
|
|
648
659
|
_isTopLevel = true;
|
|
649
660
|
if (useManifest) await syncManifestManager.load();
|
|
661
|
+
|
|
662
|
+
// Initialize progress tracking if a token is provided
|
|
663
|
+
if (progressState && progressState.token) {
|
|
664
|
+
server.notification({
|
|
665
|
+
method: "notifications/progress",
|
|
666
|
+
params: {
|
|
667
|
+
progressToken: progressState.token,
|
|
668
|
+
progress: 0,
|
|
669
|
+
total: progressState.total || 100,
|
|
670
|
+
message: "Starting synchronization..."
|
|
671
|
+
}
|
|
672
|
+
});
|
|
673
|
+
}
|
|
650
674
|
}
|
|
651
675
|
|
|
652
676
|
if (extraExclude.length > 0) {
|
|
@@ -660,11 +684,8 @@ async function syncFiles(client, useSFTP, localPath, remotePath, direction, igno
|
|
|
660
684
|
const localFilePath = path.join(localPath, file.name);
|
|
661
685
|
const remoteFilePath = `${remotePath}/${file.name}`;
|
|
662
686
|
|
|
663
|
-
// In some environments (like Windows with ftp-srv), rapid transfers cause ECONNRESET.
|
|
664
|
-
// A short delay helps stabilize the socket state during sequence (FTP only).
|
|
665
687
|
if (!useSFTP) await new Promise(r => setTimeout(r, 50));
|
|
666
688
|
|
|
667
|
-
// Security check first so we can warn even if it's in .gitignore/.ftpignore
|
|
668
689
|
if (isSecretFile(localFilePath)) {
|
|
669
690
|
if (dryRun) stats.filesToChange.push(localFilePath);
|
|
670
691
|
stats.errors.push(`Security Warning: Blocked upload of likely secret file: ${localFilePath}`);
|
|
@@ -685,18 +706,16 @@ async function syncFiles(client, useSFTP, localPath, remotePath, direction, igno
|
|
|
685
706
|
await client.ensureDir(remoteFilePath);
|
|
686
707
|
}
|
|
687
708
|
}
|
|
688
|
-
const subStats = await syncFiles(client, useSFTP, localFilePath, remoteFilePath, direction, ignorePatterns, basePath, extraExclude, dryRun);
|
|
709
|
+
const subStats = await syncFiles(client, useSFTP, localFilePath, remoteFilePath, direction, ignorePatterns, basePath, extraExclude, dryRun, useManifest, false, progressState);
|
|
689
710
|
stats.uploaded += subStats.uploaded;
|
|
690
711
|
stats.downloaded += subStats.downloaded;
|
|
691
712
|
stats.skipped += subStats.skipped;
|
|
692
713
|
stats.ignored += subStats.ignored;
|
|
693
714
|
stats.errors.push(...subStats.errors);
|
|
694
715
|
} else {
|
|
695
|
-
// isSecretFile already checked above in the loop
|
|
696
716
|
const localStat = await fs.stat(localFilePath);
|
|
697
717
|
let shouldUpload = true;
|
|
698
718
|
|
|
699
|
-
// 1. Fast check using local manifest
|
|
700
719
|
if (useManifest) {
|
|
701
720
|
const changedLocally = await syncManifestManager.isFileChanged(localFilePath, remoteFilePath, localStat);
|
|
702
721
|
if (!changedLocally) {
|
|
@@ -705,7 +724,6 @@ async function syncFiles(client, useSFTP, localPath, remotePath, direction, igno
|
|
|
705
724
|
}
|
|
706
725
|
}
|
|
707
726
|
|
|
708
|
-
// 2. Slow check using remote stat
|
|
709
727
|
if (shouldUpload) {
|
|
710
728
|
try {
|
|
711
729
|
const remoteStat = useSFTP
|
|
@@ -727,6 +745,20 @@ async function syncFiles(client, useSFTP, localPath, remotePath, direction, igno
|
|
|
727
745
|
|
|
728
746
|
if (shouldUpload) {
|
|
729
747
|
if (!dryRun) {
|
|
748
|
+
// Update progress before transfer
|
|
749
|
+
if (progressState && progressState.token) {
|
|
750
|
+
progressState.current++;
|
|
751
|
+
server.notification({
|
|
752
|
+
method: "notifications/progress",
|
|
753
|
+
params: {
|
|
754
|
+
progressToken: progressState.token,
|
|
755
|
+
progress: progressState.current,
|
|
756
|
+
total: progressState.total,
|
|
757
|
+
message: `Transferring ${file.name}...`
|
|
758
|
+
}
|
|
759
|
+
});
|
|
760
|
+
}
|
|
761
|
+
|
|
730
762
|
let attempts = 0;
|
|
731
763
|
const maxAttempts = 3;
|
|
732
764
|
let success = false;
|
|
@@ -744,7 +776,7 @@ async function syncFiles(client, useSFTP, localPath, remotePath, direction, igno
|
|
|
744
776
|
attempts++;
|
|
745
777
|
lastError = err;
|
|
746
778
|
if (attempts < maxAttempts) {
|
|
747
|
-
await new Promise(r => setTimeout(r, 100 * attempts));
|
|
779
|
+
await new Promise(r => setTimeout(r, 100 * attempts));
|
|
748
780
|
}
|
|
749
781
|
}
|
|
750
782
|
}
|
|
@@ -827,7 +859,9 @@ const server = new Server(
|
|
|
827
859
|
{
|
|
828
860
|
capabilities: {
|
|
829
861
|
tools: {},
|
|
830
|
-
resources: {},
|
|
862
|
+
resources: { subscribe: true, templates: true },
|
|
863
|
+
prompts: {},
|
|
864
|
+
logging: {}
|
|
831
865
|
},
|
|
832
866
|
}
|
|
833
867
|
);
|
|
@@ -1387,9 +1421,163 @@ server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
|
|
|
1387
1421
|
]
|
|
1388
1422
|
};
|
|
1389
1423
|
}
|
|
1424
|
+
|
|
1425
|
+
// Handle mcp://remote-file/{path} template
|
|
1426
|
+
if (request.params.uri.startsWith("mcp://remote-file/")) {
|
|
1427
|
+
const filePath = request.params.uri.replace("mcp://remote-file/", "");
|
|
1428
|
+
if (!currentConfig) throw new Error("No active connection. Use ftp_connect first.");
|
|
1429
|
+
|
|
1430
|
+
// SECURITY: Block path traversal in URI template
|
|
1431
|
+
if (filePath.includes('..')) {
|
|
1432
|
+
throw new Error(`Invalid path: Path traversal attempted in resource URI: ${filePath}`);
|
|
1433
|
+
}
|
|
1434
|
+
|
|
1435
|
+
// SECURITY: Validate against policy engine
|
|
1436
|
+
try {
|
|
1437
|
+
const policyEngine = new PolicyEngine(currentConfig || {});
|
|
1438
|
+
policyEngine.validateOperation('read', { path: filePath });
|
|
1439
|
+
} catch (e) {
|
|
1440
|
+
throw new Error(`Policy Violation: ${e.message}`);
|
|
1441
|
+
}
|
|
1442
|
+
|
|
1443
|
+
try {
|
|
1444
|
+
const entry = await getClient(currentConfig);
|
|
1445
|
+
const content = await entry.execute(async (client) => {
|
|
1446
|
+
if (client._isSFTP) {
|
|
1447
|
+
const buffer = await client.get(filePath);
|
|
1448
|
+
return buffer.toString('utf8');
|
|
1449
|
+
} else {
|
|
1450
|
+
const chunks = [];
|
|
1451
|
+
const stream = new Writable({ write(c, e, cb) { chunks.push(c); cb(); } });
|
|
1452
|
+
await client.downloadTo(stream, filePath);
|
|
1453
|
+
return Buffer.concat(chunks).toString('utf8');
|
|
1454
|
+
}
|
|
1455
|
+
});
|
|
1456
|
+
|
|
1457
|
+
return {
|
|
1458
|
+
contents: [{
|
|
1459
|
+
uri: request.params.uri,
|
|
1460
|
+
mimeType: "text/plain",
|
|
1461
|
+
text: content
|
|
1462
|
+
}]
|
|
1463
|
+
};
|
|
1464
|
+
} finally {
|
|
1465
|
+
if (currentConfig) releaseClient(currentConfig);
|
|
1466
|
+
}
|
|
1467
|
+
}
|
|
1468
|
+
|
|
1390
1469
|
throw new Error(`Resource not found: ${request.params.uri}`);
|
|
1391
1470
|
});
|
|
1392
1471
|
|
|
1472
|
+
server.setRequestHandler(ListResourceTemplatesRequestSchema, async () => {
|
|
1473
|
+
return {
|
|
1474
|
+
resourceTemplates: [
|
|
1475
|
+
{
|
|
1476
|
+
uriTemplate: "mcp://remote-file/{path}",
|
|
1477
|
+
name: "Remote File Content",
|
|
1478
|
+
description: "Read the full UTF-8 content of any remote file as an MCP resource."
|
|
1479
|
+
}
|
|
1480
|
+
]
|
|
1481
|
+
};
|
|
1482
|
+
});
|
|
1483
|
+
|
|
1484
|
+
async function countFilesRecursive(localPath, ignorePatterns = null, basePath = null) {
|
|
1485
|
+
if (ignorePatterns === null) {
|
|
1486
|
+
ignorePatterns = await loadIgnorePatterns(localPath);
|
|
1487
|
+
basePath = localPath;
|
|
1488
|
+
}
|
|
1489
|
+
|
|
1490
|
+
let count = 0;
|
|
1491
|
+
const files = await fs.readdir(localPath, { withFileTypes: true });
|
|
1492
|
+
|
|
1493
|
+
for (const file of files) {
|
|
1494
|
+
const fullPath = path.join(localPath, file.name);
|
|
1495
|
+
if (shouldIgnore(fullPath, ignorePatterns, basePath) || isSecretFile(fullPath)) continue;
|
|
1496
|
+
|
|
1497
|
+
if (file.isDirectory()) {
|
|
1498
|
+
count += await countFilesRecursive(fullPath, ignorePatterns, basePath);
|
|
1499
|
+
} else {
|
|
1500
|
+
count++;
|
|
1501
|
+
}
|
|
1502
|
+
}
|
|
1503
|
+
return count;
|
|
1504
|
+
}
|
|
1505
|
+
|
|
1506
|
+
server.setRequestHandler(ListPromptsRequestSchema, async () => {
|
|
1507
|
+
return {
|
|
1508
|
+
prompts: [
|
|
1509
|
+
{
|
|
1510
|
+
name: "audit-project",
|
|
1511
|
+
description: "Perform a security and architectural review of the remote workspace.",
|
|
1512
|
+
arguments: [
|
|
1513
|
+
{
|
|
1514
|
+
name: "path",
|
|
1515
|
+
description: "Path to audit (defaults to root)",
|
|
1516
|
+
required: false
|
|
1517
|
+
}
|
|
1518
|
+
]
|
|
1519
|
+
},
|
|
1520
|
+
{
|
|
1521
|
+
name: "deploy-checklist",
|
|
1522
|
+
description: "Guide the agent through a pre-deployment safety check.",
|
|
1523
|
+
arguments: [
|
|
1524
|
+
{
|
|
1525
|
+
name: "deployment",
|
|
1526
|
+
description: "Target deployment name from .ftpconfig",
|
|
1527
|
+
required: true
|
|
1528
|
+
}
|
|
1529
|
+
]
|
|
1530
|
+
}
|
|
1531
|
+
]
|
|
1532
|
+
};
|
|
1533
|
+
});
|
|
1534
|
+
|
|
1535
|
+
server.setRequestHandler(GetPromptRequestSchema, async (request) => {
|
|
1536
|
+
if (request.params.name === "audit-project") {
|
|
1537
|
+
const path = request.params.arguments?.path || ".";
|
|
1538
|
+
return {
|
|
1539
|
+
description: "Project Security & Architecture Audit",
|
|
1540
|
+
messages: [
|
|
1541
|
+
{
|
|
1542
|
+
role: "user",
|
|
1543
|
+
content: {
|
|
1544
|
+
type: "text",
|
|
1545
|
+
text: `Please audit the remote workspace at \`${path}\`.
|
|
1546
|
+
Follow these steps:
|
|
1547
|
+
1. Run \`ftp_analyze_workspace "${path}"\` to detect framework patterns.
|
|
1548
|
+
2. List sensitive directories to ensure no secrets are exposed.
|
|
1549
|
+
3. Search for configuration files (e.g., \`.env\`, \`config.js\`) using \`ftp_search\`.
|
|
1550
|
+
4. Review the primary dependency manifest (e.g., \`package.json\`) for security risks.
|
|
1551
|
+
Summarize your findings with a focus on potential vulnerabilities and architectural improvements.`
|
|
1552
|
+
}
|
|
1553
|
+
}
|
|
1554
|
+
]
|
|
1555
|
+
};
|
|
1556
|
+
}
|
|
1557
|
+
|
|
1558
|
+
if (request.params.name === "deploy-checklist") {
|
|
1559
|
+
const deployment = request.params.arguments?.deployment;
|
|
1560
|
+
return {
|
|
1561
|
+
description: "Pre-Deployment Safety Check",
|
|
1562
|
+
messages: [
|
|
1563
|
+
{
|
|
1564
|
+
role: "user",
|
|
1565
|
+
content: {
|
|
1566
|
+
type: "text",
|
|
1567
|
+
text: `Perform a safety check before deploying to \`${deployment}\`.
|
|
1568
|
+
1. Verify the deployment exists with \`ftp_list_deployments\`.
|
|
1569
|
+
2. Check remote disk space with \`ftp_disk_space\` (if SFTP).
|
|
1570
|
+
3. List the target remote directory to ensure no critical files are being overwritten without a backup.
|
|
1571
|
+
4. Run \`ftp_sync\` with \`dryRun: true\` to preview the changes.
|
|
1572
|
+
Report if it is safe to proceed with the actual deployment.`
|
|
1573
|
+
}
|
|
1574
|
+
}
|
|
1575
|
+
]
|
|
1576
|
+
};
|
|
1577
|
+
}
|
|
1578
|
+
throw new Error(`Prompt not found: ${request.params.name}`);
|
|
1579
|
+
});
|
|
1580
|
+
|
|
1393
1581
|
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
1394
1582
|
if (request.params.name === "ftp_list_deployments") {
|
|
1395
1583
|
try {
|
|
@@ -2079,6 +2267,21 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
2079
2267
|
const snapshotPaths = files.map(f => f.remotePath);
|
|
2080
2268
|
const batchTxId = await snapshotManager.createSnapshot(client, useSFTP, snapshotPaths);
|
|
2081
2269
|
|
|
2270
|
+
const progressToken = request.params._meta?.progressToken;
|
|
2271
|
+
if (progressToken) {
|
|
2272
|
+
server.notification({
|
|
2273
|
+
method: "notifications/progress",
|
|
2274
|
+
params: {
|
|
2275
|
+
progressToken,
|
|
2276
|
+
progress: 0,
|
|
2277
|
+
total: files.length,
|
|
2278
|
+
message: "Starting batch upload..."
|
|
2279
|
+
}
|
|
2280
|
+
});
|
|
2281
|
+
}
|
|
2282
|
+
|
|
2283
|
+
let current = 0;
|
|
2284
|
+
|
|
2082
2285
|
for (const file of files) {
|
|
2083
2286
|
// CODE-6/SEC-3: Apply same security guards as ftp_upload
|
|
2084
2287
|
try { validateLocalPath(file.localPath); } catch (e) {
|
|
@@ -2103,6 +2306,19 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
2103
2306
|
await client.uploadFrom(file.localPath, file.remotePath);
|
|
2104
2307
|
}
|
|
2105
2308
|
results.success.push(file.remotePath);
|
|
2309
|
+
|
|
2310
|
+
if (progressToken) {
|
|
2311
|
+
current++;
|
|
2312
|
+
server.notification({
|
|
2313
|
+
method: "notifications/progress",
|
|
2314
|
+
params: {
|
|
2315
|
+
progressToken,
|
|
2316
|
+
progress: current,
|
|
2317
|
+
total: files.length,
|
|
2318
|
+
message: `Uploaded ${path.basename(file.remotePath)}`
|
|
2319
|
+
}
|
|
2320
|
+
});
|
|
2321
|
+
}
|
|
2106
2322
|
} catch (error) {
|
|
2107
2323
|
results.failed.push({ path: file.remotePath, error: error.message });
|
|
2108
2324
|
}
|
|
@@ -2120,6 +2336,21 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
2120
2336
|
const { files } = request.params.arguments;
|
|
2121
2337
|
const results = { success: [], failed: [] };
|
|
2122
2338
|
|
|
2339
|
+
const progressToken = request.params._meta?.progressToken;
|
|
2340
|
+
if (progressToken) {
|
|
2341
|
+
server.notification({
|
|
2342
|
+
method: "notifications/progress",
|
|
2343
|
+
params: {
|
|
2344
|
+
progressToken,
|
|
2345
|
+
progress: 0,
|
|
2346
|
+
total: files.length,
|
|
2347
|
+
message: "Starting batch download..."
|
|
2348
|
+
}
|
|
2349
|
+
});
|
|
2350
|
+
}
|
|
2351
|
+
|
|
2352
|
+
let current = 0;
|
|
2353
|
+
|
|
2123
2354
|
for (const file of files) {
|
|
2124
2355
|
// CODE-7/SEC-2: Validate local paths to prevent path traversal
|
|
2125
2356
|
try { validateLocalPath(file.localPath); } catch (e) {
|
|
@@ -2133,6 +2364,19 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
2133
2364
|
await client.downloadTo(file.localPath, file.remotePath);
|
|
2134
2365
|
}
|
|
2135
2366
|
results.success.push(file.remotePath);
|
|
2367
|
+
|
|
2368
|
+
if (progressToken) {
|
|
2369
|
+
current++;
|
|
2370
|
+
server.notification({
|
|
2371
|
+
method: "notifications/progress",
|
|
2372
|
+
params: {
|
|
2373
|
+
progressToken,
|
|
2374
|
+
progress: current,
|
|
2375
|
+
total: files.length,
|
|
2376
|
+
message: `Downloaded ${path.basename(file.remotePath)}`
|
|
2377
|
+
}
|
|
2378
|
+
});
|
|
2379
|
+
}
|
|
2136
2380
|
} catch (error) {
|
|
2137
2381
|
results.failed.push({ path: file.remotePath, error: error.message });
|
|
2138
2382
|
}
|
|
@@ -2149,7 +2393,16 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
2149
2393
|
case "ftp_sync": {
|
|
2150
2394
|
const { localPath, remotePath, direction = "upload", dryRun = false, useManifest = true } = request.params.arguments;
|
|
2151
2395
|
const startTime = Date.now();
|
|
2152
|
-
|
|
2396
|
+
|
|
2397
|
+
const progressToken = request.params._meta?.progressToken;
|
|
2398
|
+
let progressState = null;
|
|
2399
|
+
|
|
2400
|
+
if (progressToken && !dryRun) {
|
|
2401
|
+
const total = await countFilesRecursive(localPath);
|
|
2402
|
+
progressState = { token: progressToken, current: 0, total };
|
|
2403
|
+
}
|
|
2404
|
+
|
|
2405
|
+
const stats = await syncFiles(client, useSFTP, localPath, remotePath, direction, null, null, [], dryRun, useManifest, true, progressState);
|
|
2153
2406
|
const duration = Date.now() - startTime;
|
|
2154
2407
|
|
|
2155
2408
|
if (!dryRun) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ftp-mcp",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.5.1",
|
|
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",
|