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.
Files changed (3) hide show
  1. package/README.md +33 -0
  2. package/index.js +265 -12
  3. 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.4.0";
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)); // Backoff
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
- 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);
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.4.0",
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",