ftp-mcp 1.3.1 → 1.5.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.
Files changed (3) hide show
  1. package/README.md +14 -0
  2. package/index.js +380 -18
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -104,6 +104,20 @@ Instead of requesting a 5,000-line remote file, making a 2-line edit locally, an
104
104
  ### Semantic Workspace Analysis
105
105
  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
106
 
107
+ ### Native MCP Prompts
108
+ The server provides built-in prompts that guide AI agents through complex workflows:
109
+ - **`audit-project`**: Instructs the agent on how to perform a deep security and architectural audit of a remote codebase.
110
+ - **`deploy-checklist`**: Provides a standard safety checklist for agents to verify before performing production deployments or massive synchronizations.
111
+
112
+ ### Resource Templates (Remote File Access)
113
+ Access remote files as standard MCP resources without explicitly calling a tool:
114
+ - **URI Template**: `mcp://remote-file/{path}`
115
+ - Supports direct UTF-8 reading and respects all `PolicyEngine` security boundaries and path-traversal guards.
116
+
117
+ ### Operational Transparency
118
+ - **Real-time Logging**: Emits protocol-native logging notifications for internal events like connection pooling, cache reuse, and transfer lifecycle steps.
119
+ - **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.
120
+
107
121
  ### Strict Safe-Mode Execution
108
122
  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
123
 
package/index.js CHANGED
@@ -5,6 +5,11 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
5
5
  import {
6
6
  CallToolRequestSchema,
7
7
  ListToolsRequestSchema,
8
+ ListResourcesRequestSchema,
9
+ ReadResourceRequestSchema,
10
+ ListResourceTemplatesRequestSchema,
11
+ ListPromptsRequestSchema,
12
+ GetPromptRequestSchema,
8
13
  } from "@modelcontextprotocol/sdk/types.js";
9
14
  import { Client as FTPClient } from "basic-ftp";
10
15
  import SFTPClient from "ssh2-sftp-client";
@@ -25,8 +30,41 @@ import crypto from "crypto";
25
30
  const __filename = fileURLToPath(import.meta.url);
26
31
  const __dirname = path.dirname(__filename);
27
32
 
33
+ // AI-First Semantic Icons
34
+ const ICON = {
35
+ DIR: "📂",
36
+ FILE: "📄",
37
+ PKG: "📦",
38
+ CONFIG: "⚙️",
39
+ SECRET: "🔒",
40
+ BACKUP: "🕰️",
41
+ HINT: "💡",
42
+ ERROR: "❌"
43
+ };
44
+
45
+ /**
46
+ * AI-First: Generate context-aware suggestions for troubleshooting and next steps.
47
+ * This helper ensures the LLM receives actionable, backticked commands.
48
+ */
49
+ function getAISuggestion(type, context = {}) {
50
+ switch (type) {
51
+ case 'error_enoent':
52
+ return `[AI: Path not found. Suggested fix: Check your CWD with \`ftp_list "."\` or verify the path exists with \`ftp_exists "${context.path}"\`]`;
53
+ case 'error_permission':
54
+ return `[AI: Permission denied. Suggested fix: Verify user rights with \`ftp_stat "${context.path}"\` or check if the server supports \`ftp_chmod\`]`;
55
+ case 'hint_connected':
56
+ return `[HINT: Connection active. Suggested next step: Run \`ftp_analyze_workspace "."\` to understand the project architecture.]`;
57
+ case 'hint_list_config':
58
+ return `[HINT: Found project manifests. Suggested next step: Read \`package.json\` using \`ftp_get_contents "package.json"\` to see dependencies.]`;
59
+ case 'hint_destructive_readonly':
60
+ return `[AI: Server is in READ-ONLY mode. Use \`ftp_sync --dryRun\` to simulate this deployment instead.]`;
61
+ default:
62
+ return null;
63
+ }
64
+ }
65
+
28
66
  // Read version from package.json to avoid version drift (CODE-1)
29
- let SERVER_VERSION = "1.3.1";
67
+ let SERVER_VERSION = "1.5.0";
30
68
  try {
31
69
  const pkg = JSON.parse(readFileSync(path.join(__dirname, 'package.json'), 'utf8'));
32
70
  SERVER_VERSION = pkg.version || SERVER_VERSION;
@@ -151,6 +189,7 @@ if (process.argv.includes("--init")) {
151
189
 
152
190
  let currentConfig = null;
153
191
  let currentProfile = null;
192
+ let sessionHintShown = false;
154
193
 
155
194
  const DEFAULT_IGNORE_PATTERNS = [
156
195
  'node_modules/**',
@@ -458,6 +497,10 @@ async function getClient(config) {
458
497
 
459
498
  if (existing && !existing.closed) {
460
499
  if (existing.idleTimeout) clearTimeout(existing.idleTimeout);
500
+ server.sendLoggingMessage({
501
+ level: "debug",
502
+ data: { message: `Reusing cached connection for ${poolKey}` }
503
+ });
461
504
  return existing;
462
505
  }
463
506
 
@@ -501,6 +544,10 @@ async function getClient(config) {
501
544
  }
502
545
 
503
546
  connectionPool.set(poolKey, entry);
547
+ server.sendLoggingMessage({
548
+ level: "info",
549
+ data: { message: `Successfully connected to ${poolKey} (${useSFTP ? 'SFTP' : 'FTP'})` }
550
+ });
504
551
  return entry;
505
552
  } finally {
506
553
  connectingPromises.delete(poolKey);
@@ -603,7 +650,7 @@ async function getTreeRecursive(client, useSFTP, remotePath, depth = 0, maxDepth
603
650
  return results;
604
651
  }
605
652
 
606
- 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) {
607
654
  const stats = { uploaded: 0, downloaded: 0, skipped: 0, errors: [], ignored: 0, filesToChange: [] };
608
655
 
609
656
  if (ignorePatterns === null) {
@@ -611,6 +658,19 @@ async function syncFiles(client, useSFTP, localPath, remotePath, direction, igno
611
658
  basePath = localPath;
612
659
  _isTopLevel = true;
613
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
+ }
614
674
  }
615
675
 
616
676
  if (extraExclude.length > 0) {
@@ -624,11 +684,8 @@ async function syncFiles(client, useSFTP, localPath, remotePath, direction, igno
624
684
  const localFilePath = path.join(localPath, file.name);
625
685
  const remoteFilePath = `${remotePath}/${file.name}`;
626
686
 
627
- // In some environments (like Windows with ftp-srv), rapid transfers cause ECONNRESET.
628
- // A short delay helps stabilize the socket state during sequence (FTP only).
629
687
  if (!useSFTP) await new Promise(r => setTimeout(r, 50));
630
688
 
631
- // Security check first so we can warn even if it's in .gitignore/.ftpignore
632
689
  if (isSecretFile(localFilePath)) {
633
690
  if (dryRun) stats.filesToChange.push(localFilePath);
634
691
  stats.errors.push(`Security Warning: Blocked upload of likely secret file: ${localFilePath}`);
@@ -649,18 +706,16 @@ async function syncFiles(client, useSFTP, localPath, remotePath, direction, igno
649
706
  await client.ensureDir(remoteFilePath);
650
707
  }
651
708
  }
652
- 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);
653
710
  stats.uploaded += subStats.uploaded;
654
711
  stats.downloaded += subStats.downloaded;
655
712
  stats.skipped += subStats.skipped;
656
713
  stats.ignored += subStats.ignored;
657
714
  stats.errors.push(...subStats.errors);
658
715
  } else {
659
- // isSecretFile already checked above in the loop
660
716
  const localStat = await fs.stat(localFilePath);
661
717
  let shouldUpload = true;
662
718
 
663
- // 1. Fast check using local manifest
664
719
  if (useManifest) {
665
720
  const changedLocally = await syncManifestManager.isFileChanged(localFilePath, remoteFilePath, localStat);
666
721
  if (!changedLocally) {
@@ -669,7 +724,6 @@ async function syncFiles(client, useSFTP, localPath, remotePath, direction, igno
669
724
  }
670
725
  }
671
726
 
672
- // 2. Slow check using remote stat
673
727
  if (shouldUpload) {
674
728
  try {
675
729
  const remoteStat = useSFTP
@@ -691,6 +745,20 @@ async function syncFiles(client, useSFTP, localPath, remotePath, direction, igno
691
745
 
692
746
  if (shouldUpload) {
693
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
+
694
762
  let attempts = 0;
695
763
  const maxAttempts = 3;
696
764
  let success = false;
@@ -708,7 +776,7 @@ async function syncFiles(client, useSFTP, localPath, remotePath, direction, igno
708
776
  attempts++;
709
777
  lastError = err;
710
778
  if (attempts < maxAttempts) {
711
- await new Promise(r => setTimeout(r, 100 * attempts)); // Backoff
779
+ await new Promise(r => setTimeout(r, 100 * attempts));
712
780
  }
713
781
  }
714
782
  }
@@ -786,11 +854,14 @@ function generateSemanticPreview(filesToChange) {
786
854
  const server = new Server(
787
855
  {
788
856
  name: "ftp-mcp-server",
789
- version: SERVER_VERSION, // CODE-1: reads from package.json at startup
857
+ version: SERVER_VERSION,
790
858
  },
791
859
  {
792
860
  capabilities: {
793
861
  tools: {},
862
+ resources: { subscribe: true, templates: true },
863
+ prompts: {},
864
+ logging: {}
794
865
  },
795
866
  }
796
867
  );
@@ -1308,6 +1379,205 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
1308
1379
  };
1309
1380
  });
1310
1381
 
1382
+ server.setRequestHandler(ListResourcesRequestSchema, async () => {
1383
+ return {
1384
+ resources: [
1385
+ {
1386
+ uri: "mcp://instruction/server-state",
1387
+ name: "Active Server Instruction & Context",
1388
+ description: "Provides a real-time summary of the active connection profile, security constraints, and operational hints to optimize agent behavior.",
1389
+ mimeType: "text/markdown"
1390
+ }
1391
+ ]
1392
+ };
1393
+ });
1394
+
1395
+ server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
1396
+ if (request.params.uri === "mcp://instruction/server-state") {
1397
+ const isReadOnly = currentConfig?.readOnly || false;
1398
+ const protocol = currentConfig?.host?.startsWith('sftp://') ? 'SFTP' : 'FTP';
1399
+
1400
+ const content = `# ftp-mcp Server Context (Agent Guide)
1401
+ **Current Version:** ${SERVER_VERSION}
1402
+ **Active Profile:** ${currentProfile || 'Environment Variables'}
1403
+ **Connection Mode:** ${protocol}
1404
+ **Security Status:** ${isReadOnly ? 'READ-ONLY (Destructive operations disabled)' : 'READ-WRITE'}
1405
+
1406
+ ## 💡 Operational Recommendations:
1407
+ 1. **Prefer Patches**: Use \`ftp_patch_file\` instead of \`ftp_put_contents\` for existing files to minimize token usage and bandwidth.
1408
+ 2. **Batch for Speed**: Use \`ftp_batch_upload\` and \`ftp_batch_download\` for multi-file operations.
1409
+ 3. **Workspace Context**: If this is a new codebase, run \`ftp_analyze_workspace "."\` to identify framework patterns.
1410
+ 4. **Safety**: Server uses automatic SHA-256 drift protection in snapshots. Use \`ftp_rollback\` if a refactor goes wrong.
1411
+
1412
+ [END OF SYSTEM INSTRUCTION]`;
1413
+
1414
+ return {
1415
+ contents: [
1416
+ {
1417
+ uri: request.params.uri,
1418
+ mimeType: "text/markdown",
1419
+ text: content
1420
+ }
1421
+ ]
1422
+ };
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
+
1469
+ throw new Error(`Resource not found: ${request.params.uri}`);
1470
+ });
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
+
1311
1581
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
1312
1582
  if (request.params.name === "ftp_list_deployments") {
1313
1583
  try {
@@ -1443,10 +1713,16 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1443
1713
  warning = "\n⚠️ SECURITY WARNING: You are connecting to a production profile using insecure FTP. SFTP is strongly recommended.";
1444
1714
  }
1445
1715
 
1716
+ let hint = "";
1717
+ if (!sessionHintShown) {
1718
+ hint = `\n\n${getAISuggestion('hint_connected')}`;
1719
+ sessionHintShown = true;
1720
+ }
1721
+
1446
1722
  return {
1447
1723
  content: [{
1448
1724
  type: "text",
1449
- text: `Connected to profile: ${profile || currentProfile || 'environment variables'}\nHost: ${currentConfig.host}${warning}`
1725
+ text: `Connected to profile: ${profile || currentProfile || 'environment variables'}\nHost: ${currentConfig.host}${warning}${hint}`
1450
1726
  }]
1451
1727
  };
1452
1728
  } catch (error) {
@@ -1525,14 +1801,26 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1525
1801
  const sliced = files.slice(offset, offset + limit);
1526
1802
 
1527
1803
  const formatted = sliced.map(f => {
1528
- const type = (useSFTP ? f.type === 'd' : f.isDirectory) ? 'DIR ' : 'FILE';
1804
+ const isDir = (useSFTP ? f.type === 'd' : f.isDirectory);
1805
+ const icon = isDir ? ICON.DIR : ICON.FILE;
1806
+ const label = isDir ? '[DIR] ' : '[FILE]';
1807
+
1808
+ let marker = "";
1809
+ const nameLower = f.name.toLowerCase();
1810
+ if (['package.json', 'composer.json', 'requirements.txt', 'pyproject.toml', 'go.mod'].includes(nameLower)) marker = ` ${ICON.PKG}`;
1811
+ else if (nameLower.includes('config') || nameLower.endsWith('.conf') || nameLower.endsWith('.yaml') || nameLower.endsWith('.yml')) marker = ` ${ICON.CONFIG}`;
1812
+ else if (isSecretFile(f.name)) marker = ` ${ICON.SECRET}`;
1813
+ else if (nameLower.endsWith('.bak') || nameLower.endsWith('.tmp') || nameLower.startsWith('~')) marker = ` ${ICON.BACKUP}`;
1814
+
1529
1815
  const rights = useSFTP && f.rights ? `, ${f.rights.user || ''}${f.rights.group || ''}${f.rights.other || ''}` : '';
1530
- return `${type} ${f.name} (${f.size} bytes${rights})`;
1816
+ return `${icon}${marker} ${label} ${f.name} (${f.size} bytes${rights})`;
1531
1817
  }).join('\n');
1532
1818
 
1533
1819
  const paginationInfo = `\n\nShowing ${offset + 1} to ${Math.min(offset + limit, total)} of ${total} items.`;
1820
+ const hint = total > 0 && sliced.some(f => f.name === 'package.json') ? `\n\n${getAISuggestion('hint_list_config')}` : "";
1821
+
1534
1822
  return {
1535
- content: [{ type: "text", text: (formatted || "Empty directory") + (total > limit ? paginationInfo : '') }]
1823
+ content: [{ type: "text", text: (formatted || "Empty directory") + (total > limit ? paginationInfo : '') + hint }]
1536
1824
  };
1537
1825
  }
1538
1826
 
@@ -1850,8 +2138,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1850
2138
  }
1851
2139
 
1852
2140
  const paginationInfo = `\n\nShowing ${offset + 1} to ${Math.min(offset + limit, total)} of ${total} matches.`;
2141
+ const hint = total === 0 ? `\n\n[AI: No matches found. Suggested fix: Try a broader wildcard pattern like \`*\` or verify your current \`path\` is correct.]` : "";
1853
2142
  return {
1854
- content: [{ type: "text", text: (formatted || "No matches found") + (total > limit ? paginationInfo : '') }]
2143
+ content: [{ type: "text", text: (formatted || "No matches found") + (total > limit ? paginationInfo : '') + hint }]
1855
2144
  };
1856
2145
  }
1857
2146
 
@@ -1978,6 +2267,21 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1978
2267
  const snapshotPaths = files.map(f => f.remotePath);
1979
2268
  const batchTxId = await snapshotManager.createSnapshot(client, useSFTP, snapshotPaths);
1980
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
+
1981
2285
  for (const file of files) {
1982
2286
  // CODE-6/SEC-3: Apply same security guards as ftp_upload
1983
2287
  try { validateLocalPath(file.localPath); } catch (e) {
@@ -2002,6 +2306,19 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2002
2306
  await client.uploadFrom(file.localPath, file.remotePath);
2003
2307
  }
2004
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
+ }
2005
2322
  } catch (error) {
2006
2323
  results.failed.push({ path: file.remotePath, error: error.message });
2007
2324
  }
@@ -2019,6 +2336,21 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2019
2336
  const { files } = request.params.arguments;
2020
2337
  const results = { success: [], failed: [] };
2021
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
+
2022
2354
  for (const file of files) {
2023
2355
  // CODE-7/SEC-2: Validate local paths to prevent path traversal
2024
2356
  try { validateLocalPath(file.localPath); } catch (e) {
@@ -2032,6 +2364,19 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2032
2364
  await client.downloadTo(file.localPath, file.remotePath);
2033
2365
  }
2034
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
+ }
2035
2380
  } catch (error) {
2036
2381
  results.failed.push({ path: file.remotePath, error: error.message });
2037
2382
  }
@@ -2048,7 +2393,16 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2048
2393
  case "ftp_sync": {
2049
2394
  const { localPath, remotePath, direction = "upload", dryRun = false, useManifest = true } = request.params.arguments;
2050
2395
  const startTime = Date.now();
2051
- const stats = await syncFiles(client, useSFTP, localPath, remotePath, direction, null, null, [], dryRun, useManifest);
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);
2052
2406
  const duration = Date.now() - startTime;
2053
2407
 
2054
2408
  if (!dryRun) {
@@ -2407,8 +2761,16 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2407
2761
  return response;
2408
2762
  } catch (error) {
2409
2763
  console.error(`[Fatal Tool Error] ${request.params.name}:`, error);
2764
+ let suggestion = "";
2765
+ const nameLower = error.message.toLowerCase();
2766
+ if (nameLower.includes('enoent') || nameLower.includes('not found')) {
2767
+ suggestion = `\n\n${getAISuggestion('error_enoent', { path: request.params.arguments?.path || request.params.arguments?.remotePath || 'target' })}`;
2768
+ } else if (nameLower.includes('permission') || nameLower.includes('eacces')) {
2769
+ suggestion = `\n\n${getAISuggestion('error_permission', { path: request.params.arguments?.path || request.params.arguments?.remotePath || 'target' })}`;
2770
+ }
2771
+
2410
2772
  return {
2411
- content: [{ type: "text", text: `Error: ${error.message}` }],
2773
+ content: [{ type: "text", text: `Error: ${error.message}${suggestion}` }],
2412
2774
  isError: true
2413
2775
  };
2414
2776
  } finally {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ftp-mcp",
3
- "version": "1.3.1",
3
+ "version": "1.5.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",