ftp-mcp 1.0.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/index.js ADDED
@@ -0,0 +1,1274 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
4
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
5
+ import {
6
+ CallToolRequestSchema,
7
+ ListToolsRequestSchema,
8
+ } from "@modelcontextprotocol/sdk/types.js";
9
+ import { Client as FTPClient } from "basic-ftp";
10
+ import SFTPClient from "ssh2-sftp-client";
11
+ import fs from "fs/promises";
12
+ import path from "path";
13
+ import { fileURLToPath } from "url";
14
+ import { Readable, Writable } from "stream";
15
+ import { minimatch } from "minimatch";
16
+
17
+ // --init: scaffold .ftpconfig.example into the user's current working directory
18
+ if (process.argv.includes("--init")) {
19
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
20
+ const exampleSrc = path.join(__dirname, ".ftpconfig.example");
21
+ const destExample = path.join(process.cwd(), ".ftpconfig.example");
22
+ const destConfig = path.join(process.cwd(), ".ftpconfig");
23
+
24
+ try {
25
+ await fs.copyFile(exampleSrc, destExample);
26
+ console.log(`✅ Created .ftpconfig.example in ${process.cwd()}`);
27
+
28
+ // Only create .ftpconfig if one doesn't already exist
29
+ try {
30
+ await fs.access(destConfig);
31
+ console.log(`ℹ️ .ftpconfig already exists — leaving it untouched.`);
32
+ } catch {
33
+ await fs.copyFile(exampleSrc, destConfig);
34
+ console.log(`✅ Created .ftpconfig — fill in your credentials and you're ready to go!`);
35
+ }
36
+
37
+ console.log(`\nNext steps:`);
38
+ console.log(` 1. Edit .ftpconfig with your FTP/SFTP credentials`);
39
+ console.log(` 2. Add ftp-mcp to your MCP client config (see README)`);
40
+ console.log(` 3. Done! Ask your AI to "list files on my FTP server"\n`);
41
+ } catch (err) {
42
+ console.error(`❌ Init failed: ${err.message}`);
43
+ process.exit(1);
44
+ }
45
+ process.exit(0);
46
+ }
47
+
48
+
49
+ let currentConfig = null;
50
+ let currentProfile = null;
51
+
52
+ const DEFAULT_IGNORE_PATTERNS = [
53
+ 'node_modules/**',
54
+ '.git/**',
55
+ '.env',
56
+ '.env.*',
57
+ '*.log',
58
+ '.DS_Store',
59
+ 'Thumbs.db',
60
+ '.vscode/**',
61
+ '.idea/**',
62
+ '*.swp',
63
+ '*.swo',
64
+ '*~',
65
+ '.ftpconfig',
66
+ 'npm-debug.log*',
67
+ 'yarn-debug.log*',
68
+ 'yarn-error.log*',
69
+ '.npm',
70
+ '.cache/**',
71
+ 'coverage/**',
72
+ '.nyc_output/**',
73
+ '*.pid',
74
+ '*.seed',
75
+ '*.pid.lock'
76
+ ];
77
+
78
+ async function loadIgnorePatterns(localPath) {
79
+ const patterns = [...DEFAULT_IGNORE_PATTERNS];
80
+
81
+ try {
82
+ const ftpignorePath = path.join(localPath, '.ftpignore');
83
+ const ftpignoreContent = await fs.readFile(ftpignorePath, 'utf8');
84
+ const ftpignorePatterns = ftpignoreContent
85
+ .split('\n')
86
+ .map(line => line.trim())
87
+ .filter(line => line && !line.startsWith('#'));
88
+ patterns.push(...ftpignorePatterns);
89
+ } catch (e) {
90
+ // .ftpignore doesn't exist, that's fine
91
+ }
92
+
93
+ try {
94
+ const gitignorePath = path.join(localPath, '.gitignore');
95
+ const gitignoreContent = await fs.readFile(gitignorePath, 'utf8');
96
+ const gitignorePatterns = gitignoreContent
97
+ .split('\n')
98
+ .map(line => line.trim())
99
+ .filter(line => line && !line.startsWith('#'));
100
+ patterns.push(...gitignorePatterns);
101
+ } catch (e) {
102
+ // .gitignore doesn't exist, that's fine
103
+ }
104
+
105
+ return patterns;
106
+ }
107
+
108
+ function shouldIgnore(filePath, ignorePatterns, basePath) {
109
+ const relativePath = path.relative(basePath, filePath).replace(/\\/g, '/');
110
+
111
+ for (const pattern of ignorePatterns) {
112
+ if (minimatch(relativePath, pattern, { dot: true, matchBase: true })) {
113
+ return true;
114
+ }
115
+ if (minimatch(path.basename(filePath), pattern, { dot: true })) {
116
+ return true;
117
+ }
118
+ }
119
+
120
+ return false;
121
+ }
122
+
123
+ async function loadFTPConfig(profileName = null, forceEnv = false) {
124
+ if (forceEnv) {
125
+ return {
126
+ host: process.env.FTPMCP_HOST,
127
+ user: process.env.FTPMCP_USER,
128
+ password: process.env.FTPMCP_PASSWORD,
129
+ port: process.env.FTPMCP_PORT
130
+ };
131
+ }
132
+
133
+ try {
134
+ const configPath = path.join(process.cwd(), '.ftpconfig');
135
+ const configData = await fs.readFile(configPath, 'utf8');
136
+ const config = JSON.parse(configData);
137
+
138
+ if (profileName) {
139
+ if (config[profileName]) {
140
+ currentProfile = profileName;
141
+ return config[profileName];
142
+ }
143
+ throw new Error(`Profile "${profileName}" not found in .ftpconfig`);
144
+ }
145
+
146
+ if (config.default) {
147
+ currentProfile = 'default';
148
+ return config.default;
149
+ }
150
+
151
+ const profiles = Object.keys(config);
152
+ if (profiles.length > 0) {
153
+ currentProfile = profiles[0];
154
+ return config[profiles[0]];
155
+ }
156
+
157
+ throw new Error('No profiles found in .ftpconfig');
158
+ } catch (error) {
159
+ if (error.code === 'ENOENT') {
160
+ return {
161
+ host: process.env.FTPMCP_HOST,
162
+ user: process.env.FTPMCP_USER,
163
+ password: process.env.FTPMCP_PASSWORD,
164
+ port: process.env.FTPMCP_PORT
165
+ };
166
+ }
167
+ throw error;
168
+ }
169
+ }
170
+
171
+ function getPort(host, configPort) {
172
+ if (configPort) return parseInt(configPort);
173
+ if (host && (host.includes('sftp') || host.startsWith('sftp://'))) return 22;
174
+ return 21;
175
+ }
176
+
177
+ function isSFTP(host) {
178
+ return host && (host.includes('sftp') || host.startsWith('sftp://'));
179
+ }
180
+
181
+ async function connectFTP(config) {
182
+ const client = new FTPClient();
183
+ client.ftp.verbose = false;
184
+ await client.access({
185
+ host: config.host,
186
+ user: config.user,
187
+ password: config.password,
188
+ port: getPort(config.host, config.port),
189
+ secure: config.secure || false
190
+ });
191
+ return client;
192
+ }
193
+
194
+ async function connectSFTP(config) {
195
+ const client = new SFTPClient();
196
+ await client.connect({
197
+ host: config.host.replace('sftp://', ''),
198
+ port: getPort(config.host, config.port),
199
+ username: config.user,
200
+ password: config.password
201
+ });
202
+ return client;
203
+ }
204
+
205
+ async function getTreeRecursive(client, useSFTP, remotePath, depth = 0, maxDepth = 10) {
206
+ if (depth > maxDepth) return [];
207
+
208
+ const files = useSFTP ? await client.list(remotePath) : await client.list(remotePath);
209
+ const results = [];
210
+
211
+ for (const file of files) {
212
+ const isDir = useSFTP ? file.type === 'd' : file.isDirectory;
213
+ const fileName = file.name;
214
+ const fullPath = remotePath === '.' ? fileName : `${remotePath}/${fileName}`;
215
+
216
+ results.push({
217
+ path: fullPath,
218
+ name: fileName,
219
+ isDirectory: isDir,
220
+ size: file.size,
221
+ modified: file.modifyTime || file.date
222
+ });
223
+
224
+ if (isDir && fileName !== '.' && fileName !== '..') {
225
+ const children = await getTreeRecursive(client, useSFTP, fullPath, depth + 1, maxDepth);
226
+ results.push(...children);
227
+ }
228
+ }
229
+
230
+ return results;
231
+ }
232
+
233
+ async function syncFiles(client, useSFTP, localPath, remotePath, direction, ignorePatterns = null, basePath = null, extraExclude = []) {
234
+ const stats = { uploaded: 0, downloaded: 0, skipped: 0, errors: [], ignored: 0 };
235
+
236
+ if (ignorePatterns === null) {
237
+ ignorePatterns = await loadIgnorePatterns(localPath);
238
+ basePath = localPath;
239
+ }
240
+
241
+ if (extraExclude.length > 0) {
242
+ ignorePatterns = [...ignorePatterns, ...extraExclude];
243
+ }
244
+
245
+ if (direction === 'upload' || direction === 'both') {
246
+ const localFiles = await fs.readdir(localPath, { withFileTypes: true });
247
+
248
+ for (const file of localFiles) {
249
+ const localFilePath = path.join(localPath, file.name);
250
+ const remoteFilePath = `${remotePath}/${file.name}`;
251
+
252
+ if (shouldIgnore(localFilePath, ignorePatterns, basePath)) {
253
+ stats.ignored++;
254
+ continue;
255
+ }
256
+
257
+ try {
258
+ if (file.isDirectory()) {
259
+ if (useSFTP) {
260
+ await client.mkdir(remoteFilePath, true);
261
+ } else {
262
+ await client.ensureDir(remoteFilePath);
263
+ }
264
+ const subStats = await syncFiles(client, useSFTP, localFilePath, remoteFilePath, direction, ignorePatterns, basePath, extraExclude);
265
+ stats.uploaded += subStats.uploaded;
266
+ stats.downloaded += subStats.downloaded;
267
+ stats.skipped += subStats.skipped;
268
+ stats.ignored += subStats.ignored;
269
+ stats.errors.push(...subStats.errors);
270
+ } else {
271
+ const localStat = await fs.stat(localFilePath);
272
+ let shouldUpload = true;
273
+
274
+ try {
275
+ const remoteStat = useSFTP
276
+ ? await client.stat(remoteFilePath)
277
+ : (await client.list(remotePath)).find(f => f.name === file.name);
278
+
279
+ if (remoteStat) {
280
+ const remoteTime = remoteStat.modifyTime || remoteStat.modifiedAt || new Date(remoteStat.rawModifiedAt);
281
+ if (localStat.mtime <= remoteTime) {
282
+ shouldUpload = false;
283
+ stats.skipped++;
284
+ }
285
+ }
286
+ } catch (e) {
287
+ // File doesn't exist remotely, upload it
288
+ }
289
+
290
+ if (shouldUpload) {
291
+ if (useSFTP) {
292
+ await client.put(localFilePath, remoteFilePath);
293
+ } else {
294
+ await client.uploadFrom(localFilePath, remoteFilePath);
295
+ }
296
+ stats.uploaded++;
297
+ }
298
+ }
299
+ } catch (error) {
300
+ stats.errors.push(`${localFilePath}: ${error.message}`);
301
+ }
302
+ }
303
+ }
304
+
305
+ return stats;
306
+ }
307
+
308
+ const server = new Server(
309
+ {
310
+ name: "ftp-mcp-server",
311
+ version: "2.0.0",
312
+ },
313
+ {
314
+ capabilities: {
315
+ tools: {},
316
+ },
317
+ }
318
+ );
319
+
320
+ server.setRequestHandler(ListToolsRequestSchema, async () => {
321
+ return {
322
+ tools: [
323
+ {
324
+ name: "ftp_connect",
325
+ description: "Connect to a named FTP profile from .ftpconfig",
326
+ inputSchema: {
327
+ type: "object",
328
+ properties: {
329
+ profile: {
330
+ type: "string",
331
+ description: "Profile name from .ftpconfig (e.g., 'production', 'staging')"
332
+ },
333
+ useEnv: {
334
+ type: "boolean",
335
+ description: "Force use of environment variables instead of .ftpconfig",
336
+ default: false
337
+ }
338
+ }
339
+ }
340
+ },
341
+ {
342
+ name: "ftp_deploy",
343
+ description: "Run a named deployment preset from .ftpconfig",
344
+ inputSchema: {
345
+ type: "object",
346
+ properties: {
347
+ deployment: {
348
+ type: "string",
349
+ description: "Deployment name from .ftpconfig deployments (e.g., 'deploy-frontend', 'deploy-api')"
350
+ }
351
+ },
352
+ required: ["deployment"]
353
+ }
354
+ },
355
+ {
356
+ name: "ftp_list_deployments",
357
+ description: "List all available deployment presets from .ftpconfig",
358
+ inputSchema: {
359
+ type: "object",
360
+ properties: {}
361
+ }
362
+ },
363
+ {
364
+ name: "ftp_list",
365
+ description: "List files and directories in a remote FTP/SFTP path",
366
+ inputSchema: {
367
+ type: "object",
368
+ properties: {
369
+ path: {
370
+ type: "string",
371
+ description: "Remote path to list (defaults to current directory)",
372
+ default: "."
373
+ }
374
+ }
375
+ }
376
+ },
377
+ {
378
+ name: "ftp_get_contents",
379
+ description: "Read file content directly from FTP/SFTP without downloading",
380
+ inputSchema: {
381
+ type: "object",
382
+ properties: {
383
+ path: {
384
+ type: "string",
385
+ description: "Remote file path to read"
386
+ }
387
+ },
388
+ required: ["path"]
389
+ }
390
+ },
391
+ {
392
+ name: "ftp_put_contents",
393
+ description: "Write content directly to FTP/SFTP file without local file",
394
+ inputSchema: {
395
+ type: "object",
396
+ properties: {
397
+ path: {
398
+ type: "string",
399
+ description: "Remote file path to write"
400
+ },
401
+ content: {
402
+ type: "string",
403
+ description: "Content to write to the file"
404
+ }
405
+ },
406
+ required: ["path", "content"]
407
+ }
408
+ },
409
+ {
410
+ name: "ftp_stat",
411
+ description: "Get file metadata (size, modified date, permissions)",
412
+ inputSchema: {
413
+ type: "object",
414
+ properties: {
415
+ path: {
416
+ type: "string",
417
+ description: "Remote file path"
418
+ }
419
+ },
420
+ required: ["path"]
421
+ }
422
+ },
423
+ {
424
+ name: "ftp_exists",
425
+ description: "Check if file or folder exists on FTP/SFTP server",
426
+ inputSchema: {
427
+ type: "object",
428
+ properties: {
429
+ path: {
430
+ type: "string",
431
+ description: "Remote path to check"
432
+ }
433
+ },
434
+ required: ["path"]
435
+ }
436
+ },
437
+ {
438
+ name: "ftp_tree",
439
+ description: "Get recursive directory listing (entire structure at once)",
440
+ inputSchema: {
441
+ type: "object",
442
+ properties: {
443
+ path: {
444
+ type: "string",
445
+ description: "Remote path to start tree from",
446
+ default: "."
447
+ },
448
+ maxDepth: {
449
+ type: "number",
450
+ description: "Maximum depth to recurse",
451
+ default: 10
452
+ }
453
+ }
454
+ }
455
+ },
456
+ {
457
+ name: "ftp_search",
458
+ description: "Find files by name pattern on FTP/SFTP server",
459
+ inputSchema: {
460
+ type: "object",
461
+ properties: {
462
+ pattern: {
463
+ type: "string",
464
+ description: "Search pattern (supports wildcards like *.js)"
465
+ },
466
+ path: {
467
+ type: "string",
468
+ description: "Remote path to search in",
469
+ default: "."
470
+ }
471
+ },
472
+ required: ["pattern"]
473
+ }
474
+ },
475
+ {
476
+ name: "ftp_copy",
477
+ description: "Duplicate files on server (SFTP only)",
478
+ inputSchema: {
479
+ type: "object",
480
+ properties: {
481
+ sourcePath: {
482
+ type: "string",
483
+ description: "Source file path"
484
+ },
485
+ destPath: {
486
+ type: "string",
487
+ description: "Destination file path"
488
+ }
489
+ },
490
+ required: ["sourcePath", "destPath"]
491
+ }
492
+ },
493
+ {
494
+ name: "ftp_batch_upload",
495
+ description: "Upload multiple files at once",
496
+ inputSchema: {
497
+ type: "object",
498
+ properties: {
499
+ files: {
500
+ type: "array",
501
+ description: "Array of {localPath, remotePath} objects",
502
+ items: {
503
+ type: "object",
504
+ properties: {
505
+ localPath: { type: "string" },
506
+ remotePath: { type: "string" }
507
+ },
508
+ required: ["localPath", "remotePath"]
509
+ }
510
+ }
511
+ },
512
+ required: ["files"]
513
+ }
514
+ },
515
+ {
516
+ name: "ftp_batch_download",
517
+ description: "Download multiple files at once",
518
+ inputSchema: {
519
+ type: "object",
520
+ properties: {
521
+ files: {
522
+ type: "array",
523
+ description: "Array of {remotePath, localPath} objects",
524
+ items: {
525
+ type: "object",
526
+ properties: {
527
+ remotePath: { type: "string" },
528
+ localPath: { type: "string" }
529
+ },
530
+ required: ["remotePath", "localPath"]
531
+ }
532
+ }
533
+ },
534
+ required: ["files"]
535
+ }
536
+ },
537
+ {
538
+ name: "ftp_sync",
539
+ description: "Smart sync local ↔ remote (only changed files)",
540
+ inputSchema: {
541
+ type: "object",
542
+ properties: {
543
+ localPath: {
544
+ type: "string",
545
+ description: "Local directory path"
546
+ },
547
+ remotePath: {
548
+ type: "string",
549
+ description: "Remote directory path"
550
+ },
551
+ direction: {
552
+ type: "string",
553
+ description: "Sync direction: 'upload', 'download', or 'both'",
554
+ enum: ["upload", "download", "both"],
555
+ default: "upload"
556
+ }
557
+ },
558
+ required: ["localPath", "remotePath"]
559
+ }
560
+ },
561
+ {
562
+ name: "ftp_disk_space",
563
+ description: "Check available space on server (SFTP only)",
564
+ inputSchema: {
565
+ type: "object",
566
+ properties: {
567
+ path: {
568
+ type: "string",
569
+ description: "Remote path to check",
570
+ default: "."
571
+ }
572
+ }
573
+ }
574
+ },
575
+ {
576
+ name: "ftp_upload",
577
+ description: "Upload a file to the FTP/SFTP server",
578
+ inputSchema: {
579
+ type: "object",
580
+ properties: {
581
+ localPath: {
582
+ type: "string",
583
+ description: "Local file path to upload"
584
+ },
585
+ remotePath: {
586
+ type: "string",
587
+ description: "Remote destination path"
588
+ }
589
+ },
590
+ required: ["localPath", "remotePath"]
591
+ }
592
+ },
593
+ {
594
+ name: "ftp_download",
595
+ description: "Download a file from the FTP/SFTP server",
596
+ inputSchema: {
597
+ type: "object",
598
+ properties: {
599
+ remotePath: {
600
+ type: "string",
601
+ description: "Remote file path to download"
602
+ },
603
+ localPath: {
604
+ type: "string",
605
+ description: "Local destination path"
606
+ }
607
+ },
608
+ required: ["remotePath", "localPath"]
609
+ }
610
+ },
611
+ {
612
+ name: "ftp_delete",
613
+ description: "Delete a file from the FTP/SFTP server",
614
+ inputSchema: {
615
+ type: "object",
616
+ properties: {
617
+ path: {
618
+ type: "string",
619
+ description: "Remote file path to delete"
620
+ }
621
+ },
622
+ required: ["path"]
623
+ }
624
+ },
625
+ {
626
+ name: "ftp_mkdir",
627
+ description: "Create a directory on the FTP/SFTP server",
628
+ inputSchema: {
629
+ type: "object",
630
+ properties: {
631
+ path: {
632
+ type: "string",
633
+ description: "Remote directory path to create"
634
+ }
635
+ },
636
+ required: ["path"]
637
+ }
638
+ },
639
+ {
640
+ name: "ftp_rmdir",
641
+ description: "Remove a directory from the FTP/SFTP server",
642
+ inputSchema: {
643
+ type: "object",
644
+ properties: {
645
+ path: {
646
+ type: "string",
647
+ description: "Remote directory path to remove"
648
+ },
649
+ recursive: {
650
+ type: "boolean",
651
+ description: "Remove directory recursively",
652
+ default: false
653
+ }
654
+ },
655
+ required: ["path"]
656
+ }
657
+ },
658
+ {
659
+ name: "ftp_chmod",
660
+ description: "Change file permissions on the FTP/SFTP server (SFTP only)",
661
+ inputSchema: {
662
+ type: "object",
663
+ properties: {
664
+ path: {
665
+ type: "string",
666
+ description: "Remote file path"
667
+ },
668
+ mode: {
669
+ type: "string",
670
+ description: "Permission mode in octal (e.g., '755', '644')"
671
+ }
672
+ },
673
+ required: ["path", "mode"]
674
+ }
675
+ },
676
+ {
677
+ name: "ftp_rename",
678
+ description: "Rename or move a file on the FTP/SFTP server",
679
+ inputSchema: {
680
+ type: "object",
681
+ properties: {
682
+ oldPath: {
683
+ type: "string",
684
+ description: "Current file path"
685
+ },
686
+ newPath: {
687
+ type: "string",
688
+ description: "New file path"
689
+ }
690
+ },
691
+ required: ["oldPath", "newPath"]
692
+ }
693
+ }
694
+ ]
695
+ };
696
+ });
697
+
698
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
699
+ if (request.params.name === "ftp_list_deployments") {
700
+ try {
701
+ const configPath = path.join(process.cwd(), '.ftpconfig');
702
+ const configData = await fs.readFile(configPath, 'utf8');
703
+ const config = JSON.parse(configData);
704
+
705
+ if (!config.deployments || Object.keys(config.deployments).length === 0) {
706
+ return {
707
+ content: [{
708
+ type: "text",
709
+ text: "No deployments configured in .ftpconfig"
710
+ }]
711
+ };
712
+ }
713
+
714
+ const deploymentList = Object.entries(config.deployments).map(([name, deploy]) => {
715
+ return `${name}\n Profile: ${deploy.profile}\n Local: ${deploy.local}\n Remote: ${deploy.remote}\n Description: ${deploy.description || 'N/A'}`;
716
+ }).join('\n\n');
717
+
718
+ return {
719
+ content: [{
720
+ type: "text",
721
+ text: `Available deployments:\n\n${deploymentList}`
722
+ }]
723
+ };
724
+ } catch (error) {
725
+ return {
726
+ content: [{ type: "text", text: `Error: ${error.message}` }],
727
+ isError: true
728
+ };
729
+ }
730
+ }
731
+
732
+ if (request.params.name === "ftp_deploy") {
733
+ try {
734
+ const { deployment } = request.params.arguments;
735
+ const configPath = path.join(process.cwd(), '.ftpconfig');
736
+ const configData = await fs.readFile(configPath, 'utf8');
737
+ const config = JSON.parse(configData);
738
+
739
+ if (!config.deployments || !config.deployments[deployment]) {
740
+ return {
741
+ content: [{
742
+ type: "text",
743
+ text: `Deployment "${deployment}" not found in .ftpconfig. Use ftp_list_deployments to see available deployments.`
744
+ }],
745
+ isError: true
746
+ };
747
+ }
748
+
749
+ const deployConfig = config.deployments[deployment];
750
+ const profileConfig = config[deployConfig.profile];
751
+
752
+ if (!profileConfig) {
753
+ return {
754
+ content: [{
755
+ type: "text",
756
+ text: `Profile "${deployConfig.profile}" not found in .ftpconfig`
757
+ }],
758
+ isError: true
759
+ };
760
+ }
761
+
762
+ currentConfig = profileConfig;
763
+ currentProfile = deployConfig.profile;
764
+
765
+ const useSFTP = isSFTP(currentConfig.host);
766
+ const client = useSFTP ? await connectSFTP(currentConfig) : await connectFTP(currentConfig);
767
+
768
+ try {
769
+ const localPath = path.resolve(deployConfig.local);
770
+ const stats = await syncFiles(
771
+ client,
772
+ useSFTP,
773
+ localPath,
774
+ deployConfig.remote,
775
+ 'upload',
776
+ null,
777
+ null,
778
+ deployConfig.exclude || []
779
+ );
780
+
781
+ return {
782
+ content: [{
783
+ type: "text",
784
+ text: `Deployment "${deployment}" complete:\n${deployConfig.description || ''}\n\nProfile: ${deployConfig.profile}\nLocal: ${deployConfig.local}\nRemote: ${deployConfig.remote}\n\nUploaded: ${stats.uploaded}\nSkipped: ${stats.skipped}\nIgnored: ${stats.ignored}\n${stats.errors.length > 0 ? '\nErrors:\n' + stats.errors.join('\n') : ''}`
785
+ }]
786
+ };
787
+ } finally {
788
+ if (useSFTP) {
789
+ await client.end();
790
+ } else {
791
+ client.close();
792
+ }
793
+ }
794
+ } catch (error) {
795
+ return {
796
+ content: [{ type: "text", text: `Error: ${error.message}` }],
797
+ isError: true
798
+ };
799
+ }
800
+ }
801
+
802
+ if (request.params.name === "ftp_connect") {
803
+ try {
804
+ const { profile, useEnv } = request.params.arguments || {};
805
+ currentConfig = await loadFTPConfig(profile, useEnv);
806
+
807
+ if (!currentConfig.host || !currentConfig.user || !currentConfig.password) {
808
+ return {
809
+ content: [
810
+ {
811
+ type: "text",
812
+ text: "Error: FTP credentials not configured. Please set FTPMCP_HOST, FTPMCP_USER, and FTPMCP_PASSWORD environment variables or create a .ftpconfig file."
813
+ }
814
+ ]
815
+ };
816
+ }
817
+
818
+ return {
819
+ content: [{
820
+ type: "text",
821
+ text: `Connected to profile: ${profile || currentProfile || 'environment variables'}\nHost: ${currentConfig.host}`
822
+ }]
823
+ };
824
+ } catch (error) {
825
+ return {
826
+ content: [{ type: "text", text: `Error: ${error.message}` }],
827
+ isError: true
828
+ };
829
+ }
830
+ }
831
+
832
+ if (!currentConfig) {
833
+ try {
834
+ currentConfig = await loadFTPConfig();
835
+ } catch (error) {
836
+ return {
837
+ content: [
838
+ {
839
+ type: "text",
840
+ text: "Error: FTP credentials not configured. Please use ftp_connect first or set environment variables."
841
+ }
842
+ ]
843
+ };
844
+ }
845
+ }
846
+
847
+ if (!currentConfig.host || !currentConfig.user || !currentConfig.password) {
848
+ return {
849
+ content: [
850
+ {
851
+ type: "text",
852
+ text: "Error: FTP credentials not configured. Please set FTPMCP_HOST, FTPMCP_USER, and FTPMCP_PASSWORD environment variables or create a .ftpconfig file."
853
+ }
854
+ ]
855
+ };
856
+ }
857
+
858
+ const useSFTP = isSFTP(currentConfig.host);
859
+ let client;
860
+
861
+ try {
862
+ client = useSFTP ? await connectSFTP(currentConfig) : await connectFTP(currentConfig);
863
+
864
+ switch (request.params.name) {
865
+ case "ftp_list": {
866
+ const path = request.params.arguments?.path || ".";
867
+ let files;
868
+
869
+ if (useSFTP) {
870
+ files = await client.list(path);
871
+ const formatted = files.map(f =>
872
+ `${f.type === 'd' ? 'DIR' : 'FILE'} ${f.name} (${f.size} bytes, ${f.rights?.user}${f.rights?.group}${f.rights?.other})`
873
+ ).join('\n');
874
+ return {
875
+ content: [{ type: "text", text: formatted || "Empty directory" }]
876
+ };
877
+ } else {
878
+ files = await client.list(path);
879
+ const formatted = files.map(f =>
880
+ `${f.isDirectory ? 'DIR' : 'FILE'} ${f.name} (${f.size} bytes)`
881
+ ).join('\n');
882
+ return {
883
+ content: [{ type: "text", text: formatted || "Empty directory" }]
884
+ };
885
+ }
886
+ }
887
+
888
+ case "ftp_get_contents": {
889
+ const { path } = request.params.arguments;
890
+ let content;
891
+
892
+ if (useSFTP) {
893
+ const buffer = await client.get(path);
894
+ content = buffer.toString('utf8');
895
+ } else {
896
+ const chunks = [];
897
+ const stream = new Writable({
898
+ write(chunk, encoding, callback) {
899
+ chunks.push(chunk);
900
+ callback();
901
+ }
902
+ });
903
+
904
+ await client.downloadTo(stream, path);
905
+ content = Buffer.concat(chunks).toString('utf8');
906
+ }
907
+
908
+ return {
909
+ content: [{ type: "text", text: content }]
910
+ };
911
+ }
912
+
913
+ case "ftp_put_contents": {
914
+ const { path, content } = request.params.arguments;
915
+
916
+ if (useSFTP) {
917
+ const buffer = Buffer.from(content, 'utf8');
918
+ await client.put(buffer, path);
919
+ } else {
920
+ const readable = Readable.from([content]);
921
+ await client.uploadFrom(readable, path);
922
+ }
923
+
924
+ return {
925
+ content: [{ type: "text", text: `Successfully wrote content to ${path}` }]
926
+ };
927
+ }
928
+
929
+ case "ftp_stat": {
930
+ const { path } = request.params.arguments;
931
+
932
+ if (useSFTP) {
933
+ const stats = await client.stat(path);
934
+ return {
935
+ content: [{
936
+ type: "text",
937
+ text: JSON.stringify({
938
+ size: stats.size,
939
+ modified: stats.modifyTime,
940
+ accessed: stats.accessTime,
941
+ permissions: stats.mode,
942
+ isDirectory: stats.isDirectory,
943
+ isFile: stats.isFile
944
+ }, null, 2)
945
+ }]
946
+ };
947
+ } else {
948
+ const dirPath = path.substring(0, path.lastIndexOf('/')) || '.';
949
+ const fileName = path.substring(path.lastIndexOf('/') + 1);
950
+ const files = await client.list(dirPath);
951
+ const file = files.find(f => f.name === fileName);
952
+
953
+ if (!file) {
954
+ throw new Error(`File not found: ${path}`);
955
+ }
956
+
957
+ return {
958
+ content: [{
959
+ type: "text",
960
+ text: JSON.stringify({
961
+ size: file.size,
962
+ modified: file.modifiedAt || file.rawModifiedAt,
963
+ isDirectory: file.isDirectory,
964
+ isFile: file.isFile
965
+ }, null, 2)
966
+ }]
967
+ };
968
+ }
969
+ }
970
+
971
+ case "ftp_exists": {
972
+ const { path } = request.params.arguments;
973
+ let exists = false;
974
+
975
+ try {
976
+ if (useSFTP) {
977
+ await client.stat(path);
978
+ exists = true;
979
+ } else {
980
+ const dirPath = path.substring(0, path.lastIndexOf('/')) || '.';
981
+ const fileName = path.substring(path.lastIndexOf('/') + 1);
982
+ const files = await client.list(dirPath);
983
+ exists = files.some(f => f.name === fileName);
984
+ }
985
+ } catch (e) {
986
+ exists = false;
987
+ }
988
+
989
+ return {
990
+ content: [{ type: "text", text: exists ? "true" : "false" }]
991
+ };
992
+ }
993
+
994
+ case "ftp_tree": {
995
+ const { path = ".", maxDepth = 10 } = request.params.arguments || {};
996
+ const tree = await getTreeRecursive(client, useSFTP, path, 0, maxDepth);
997
+
998
+ const formatted = tree.map(item => {
999
+ const indent = ' '.repeat((item.path.match(/\//g) || []).length);
1000
+ return `${indent}${item.isDirectory ? '📁' : '📄'} ${item.name} ${!item.isDirectory ? `(${item.size} bytes)` : ''}`;
1001
+ }).join('\n');
1002
+
1003
+ return {
1004
+ content: [{ type: "text", text: formatted || "Empty directory" }]
1005
+ };
1006
+ }
1007
+
1008
+ case "ftp_search": {
1009
+ const { pattern, path = "." } = request.params.arguments;
1010
+ const tree = await getTreeRecursive(client, useSFTP, path, 0, 10);
1011
+
1012
+ const regex = new RegExp(pattern.replace(/\*/g, '.*').replace(/\?/g, '.'));
1013
+ const matches = tree.filter(item => regex.test(item.name));
1014
+
1015
+ const formatted = matches.map(item =>
1016
+ `${item.path} (${item.isDirectory ? 'DIR' : item.size + ' bytes'})`
1017
+ ).join('\n');
1018
+
1019
+ return {
1020
+ content: [{ type: "text", text: formatted || "No matches found" }]
1021
+ };
1022
+ }
1023
+
1024
+ case "ftp_copy": {
1025
+ const { sourcePath, destPath } = request.params.arguments;
1026
+
1027
+ if (!useSFTP) {
1028
+ return {
1029
+ content: [{ type: "text", text: "Error: ftp_copy is only supported for SFTP connections. For FTP, download and re-upload." }]
1030
+ };
1031
+ }
1032
+
1033
+ const buffer = await client.get(sourcePath);
1034
+ await client.put(buffer, destPath);
1035
+
1036
+ return {
1037
+ content: [{ type: "text", text: `Successfully copied ${sourcePath} to ${destPath}` }]
1038
+ };
1039
+ }
1040
+
1041
+ case "ftp_batch_upload": {
1042
+ const { files } = request.params.arguments;
1043
+ const results = { success: [], failed: [] };
1044
+
1045
+ for (const file of files) {
1046
+ try {
1047
+ if (useSFTP) {
1048
+ await client.put(file.localPath, file.remotePath);
1049
+ } else {
1050
+ await client.uploadFrom(file.localPath, file.remotePath);
1051
+ }
1052
+ results.success.push(file.remotePath);
1053
+ } catch (error) {
1054
+ results.failed.push({ path: file.remotePath, error: error.message });
1055
+ }
1056
+ }
1057
+
1058
+ return {
1059
+ content: [{
1060
+ type: "text",
1061
+ text: `Uploaded: ${results.success.length}\nFailed: ${results.failed.length}\n${results.failed.length > 0 ? '\nErrors:\n' + results.failed.map(f => `${f.path}: ${f.error}`).join('\n') : ''}`
1062
+ }]
1063
+ };
1064
+ }
1065
+
1066
+ case "ftp_batch_download": {
1067
+ const { files } = request.params.arguments;
1068
+ const results = { success: [], failed: [] };
1069
+
1070
+ for (const file of files) {
1071
+ try {
1072
+ if (useSFTP) {
1073
+ await client.get(file.remotePath, file.localPath);
1074
+ } else {
1075
+ await client.downloadTo(file.localPath, file.remotePath);
1076
+ }
1077
+ results.success.push(file.remotePath);
1078
+ } catch (error) {
1079
+ results.failed.push({ path: file.remotePath, error: error.message });
1080
+ }
1081
+ }
1082
+
1083
+ return {
1084
+ content: [{
1085
+ type: "text",
1086
+ text: `Downloaded: ${results.success.length}\nFailed: ${results.failed.length}\n${results.failed.length > 0 ? '\nErrors:\n' + results.failed.map(f => `${f.path}: ${f.error}`).join('\n') : ''}`
1087
+ }]
1088
+ };
1089
+ }
1090
+
1091
+ case "ftp_sync": {
1092
+ const { localPath, remotePath, direction = "upload" } = request.params.arguments;
1093
+ const stats = await syncFiles(client, useSFTP, localPath, remotePath, direction);
1094
+
1095
+ return {
1096
+ content: [{
1097
+ type: "text",
1098
+ text: `Sync complete:\nUploaded: ${stats.uploaded}\nDownloaded: ${stats.downloaded}\nSkipped: ${stats.skipped}\nIgnored: ${stats.ignored}\n${stats.errors.length > 0 ? '\nErrors:\n' + stats.errors.join('\n') : ''}`
1099
+ }]
1100
+ };
1101
+ }
1102
+
1103
+ case "ftp_disk_space": {
1104
+ const { path = "." } = request.params.arguments || {};
1105
+
1106
+ if (!useSFTP) {
1107
+ return {
1108
+ content: [{ type: "text", text: "Error: ftp_disk_space is only supported for SFTP connections" }]
1109
+ };
1110
+ }
1111
+
1112
+ try {
1113
+ const sftp = await client.sftp();
1114
+ const diskSpace = await new Promise((resolve, reject) => {
1115
+ sftp.ext_openssh_statvfs(path, (err, stats) => {
1116
+ if (err) reject(err);
1117
+ else resolve(stats);
1118
+ });
1119
+ });
1120
+
1121
+ return {
1122
+ content: [{
1123
+ type: "text",
1124
+ text: JSON.stringify({
1125
+ total: diskSpace.blocks * diskSpace.bsize,
1126
+ free: diskSpace.bfree * diskSpace.bsize,
1127
+ available: diskSpace.bavail * diskSpace.bsize,
1128
+ used: (diskSpace.blocks - diskSpace.bfree) * diskSpace.bsize
1129
+ }, null, 2)
1130
+ }]
1131
+ };
1132
+ } catch (error) {
1133
+ return {
1134
+ content: [{ type: "text", text: `Disk space info not available: ${error.message}` }]
1135
+ };
1136
+ }
1137
+ }
1138
+
1139
+ case "ftp_upload": {
1140
+ const { localPath, remotePath } = request.params.arguments;
1141
+
1142
+ if (useSFTP) {
1143
+ await client.put(localPath, remotePath);
1144
+ } else {
1145
+ await client.uploadFrom(localPath, remotePath);
1146
+ }
1147
+
1148
+ return {
1149
+ content: [{ type: "text", text: `Successfully uploaded ${localPath} to ${remotePath}` }]
1150
+ };
1151
+ }
1152
+
1153
+ case "ftp_download": {
1154
+ const { remotePath, localPath } = request.params.arguments;
1155
+
1156
+ if (useSFTP) {
1157
+ await client.get(remotePath, localPath);
1158
+ } else {
1159
+ await client.downloadTo(localPath, remotePath);
1160
+ }
1161
+
1162
+ return {
1163
+ content: [{ type: "text", text: `Successfully downloaded ${remotePath} to ${localPath}` }]
1164
+ };
1165
+ }
1166
+
1167
+ case "ftp_delete": {
1168
+ const { path } = request.params.arguments;
1169
+
1170
+ if (useSFTP) {
1171
+ await client.delete(path);
1172
+ } else {
1173
+ await client.remove(path);
1174
+ }
1175
+
1176
+ return {
1177
+ content: [{ type: "text", text: `Successfully deleted ${path}` }]
1178
+ };
1179
+ }
1180
+
1181
+ case "ftp_mkdir": {
1182
+ const { path } = request.params.arguments;
1183
+
1184
+ if (useSFTP) {
1185
+ await client.mkdir(path, true);
1186
+ } else {
1187
+ await client.ensureDir(path);
1188
+ }
1189
+
1190
+ return {
1191
+ content: [{ type: "text", text: `Successfully created directory ${path}` }]
1192
+ };
1193
+ }
1194
+
1195
+ case "ftp_rmdir": {
1196
+ const { path, recursive } = request.params.arguments;
1197
+
1198
+ if (useSFTP) {
1199
+ await client.rmdir(path, recursive);
1200
+ } else {
1201
+ if (recursive) {
1202
+ await client.removeDir(path);
1203
+ } else {
1204
+ await client.remove(path);
1205
+ }
1206
+ }
1207
+
1208
+ return {
1209
+ content: [{ type: "text", text: `Successfully removed directory ${path}` }]
1210
+ };
1211
+ }
1212
+
1213
+ case "ftp_chmod": {
1214
+ const { path, mode } = request.params.arguments;
1215
+
1216
+ if (!useSFTP) {
1217
+ return {
1218
+ content: [{ type: "text", text: "Error: chmod is only supported for SFTP connections" }]
1219
+ };
1220
+ }
1221
+
1222
+ await client.chmod(path, mode);
1223
+
1224
+ return {
1225
+ content: [{ type: "text", text: `Successfully changed permissions of ${path} to ${mode}` }]
1226
+ };
1227
+ }
1228
+
1229
+ case "ftp_rename": {
1230
+ const { oldPath, newPath } = request.params.arguments;
1231
+
1232
+ if (useSFTP) {
1233
+ await client.rename(oldPath, newPath);
1234
+ } else {
1235
+ await client.rename(oldPath, newPath);
1236
+ }
1237
+
1238
+ return {
1239
+ content: [{ type: "text", text: `Successfully renamed ${oldPath} to ${newPath}` }]
1240
+ };
1241
+ }
1242
+
1243
+ default:
1244
+ return {
1245
+ content: [{ type: "text", text: `Unknown tool: ${request.params.name}` }],
1246
+ isError: true
1247
+ };
1248
+ }
1249
+ } catch (error) {
1250
+ return {
1251
+ content: [{ type: "text", text: `Error: ${error.message}` }],
1252
+ isError: true
1253
+ };
1254
+ } finally {
1255
+ if (client) {
1256
+ if (useSFTP) {
1257
+ await client.end();
1258
+ } else {
1259
+ client.close();
1260
+ }
1261
+ }
1262
+ }
1263
+ });
1264
+
1265
+ async function main() {
1266
+ const transport = new StdioServerTransport();
1267
+ await server.connect(transport);
1268
+ console.error("FTP MCP Server running on stdio");
1269
+ }
1270
+
1271
+ main().catch((error) => {
1272
+ console.error("Fatal error:", error);
1273
+ process.exit(1);
1274
+ });