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/.ftpconfig.example +48 -0
- package/README.md +717 -0
- package/index.js +1274 -0
- package/package.json +83 -0
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
|
+
});
|