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.
- package/README.md +14 -0
- package/index.js +380 -18
- 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.
|
|
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));
|
|
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,
|
|
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
|
|
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 `${
|
|
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
|
-
|
|
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
|
+
"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",
|