@prmichaelsen/acp-mcp 0.7.1 → 1.0.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.
@@ -1,6 +1,5 @@
1
- import { Client, SFTPWrapper } from 'ssh2';
1
+ import { Client } from 'ssh2';
2
2
  import { SSHConfig } from '../types/ssh-config.js';
3
- import { FileEntry, parsePermissions, getFileType } from '../types/file-entry.js';
4
3
  import { logger } from './logger.js';
5
4
 
6
5
  /**
@@ -230,308 +229,6 @@ export class SSHConnectionManager {
230
229
  });
231
230
  }
232
231
 
233
- /**
234
- * Get SFTP wrapper for file operations
235
- */
236
- async getSFTP(): Promise<SFTPWrapper> {
237
- if (!this.connected) {
238
- await this.connect();
239
- }
240
-
241
- return new Promise((resolve, reject) => {
242
- this.client.sftp((err, sftp) => {
243
- if (err) {
244
- reject(err);
245
- } else {
246
- resolve(sftp);
247
- }
248
- });
249
- });
250
- }
251
-
252
- /**
253
- * List files in a directory with comprehensive metadata
254
- * Uses hybrid approach: shell ls for filenames (includes hidden), SFTP stat for metadata
255
- *
256
- * @param path - Directory path to list
257
- * @param includeHidden - Whether to include hidden files (default: true)
258
- * @returns Array of FileEntry objects with complete metadata
259
- */
260
- async listFiles(path: string, includeHidden: boolean = true): Promise<FileEntry[]> {
261
- const startTime = Date.now();
262
- logger.debug('Listing files', { path, includeHidden });
263
-
264
- try {
265
- // Step 1: Use shell command to get ALL filenames (including hidden)
266
- const lsFlag = includeHidden ? '-A' : '';
267
- const command = `ls ${lsFlag} -1 "${path}" 2>/dev/null`;
268
- const result = await this.execWithTimeout(command, 10);
269
-
270
- if (result.exitCode !== 0) {
271
- throw new Error(`ls command failed: ${result.stderr}`);
272
- }
273
-
274
- const filenames = result.stdout
275
- .split('\n')
276
- .map(f => f.trim())
277
- .filter(f => f !== '' && f !== '.' && f !== '..');
278
-
279
- logger.debug('Filenames retrieved via shell', {
280
- path,
281
- count: filenames.length,
282
- method: 'shell',
283
- });
284
-
285
- // Step 2: Get rich metadata for each file using SFTP stat()
286
- const sftp = await this.getSFTP();
287
- const entries: FileEntry[] = [];
288
-
289
- for (const filename of filenames) {
290
- const fullPath = `${path}/${filename}`.replace(/\/+/g, '/');
291
-
292
- try {
293
- const stats = await new Promise<any>((resolve, reject) => {
294
- sftp.stat(fullPath, (err, stats) => {
295
- if (err) reject(err);
296
- else resolve(stats);
297
- });
298
- });
299
-
300
- entries.push({
301
- name: filename,
302
- path: fullPath,
303
- type: getFileType(stats),
304
- size: stats.size,
305
- permissions: parsePermissions(stats.mode),
306
- owner: {
307
- uid: stats.uid,
308
- gid: stats.gid,
309
- },
310
- timestamps: {
311
- accessed: new Date(stats.atime * 1000).toISOString(),
312
- modified: new Date(stats.mtime * 1000).toISOString(),
313
- },
314
- });
315
- } catch (error) {
316
- // Skip files we can't stat (permissions, race conditions, etc.)
317
- logger.warn('Failed to stat file, skipping', {
318
- path: fullPath,
319
- error: error instanceof Error ? error.message : String(error),
320
- });
321
- }
322
- }
323
-
324
- const duration = Date.now() - startTime;
325
- logger.debug('Files listed successfully', {
326
- path,
327
- count: entries.length,
328
- duration: `${duration}ms`,
329
- method: 'hybrid',
330
- });
331
-
332
- return entries;
333
- } catch (error) {
334
- // Fallback to SFTP readdir if shell command fails
335
- logger.warn('Shell ls command failed, falling back to SFTP readdir', {
336
- path,
337
- error: error instanceof Error ? error.message : String(error),
338
- });
339
-
340
- return this.listFilesViaSFTP(path, includeHidden);
341
- }
342
- }
343
-
344
- /**
345
- * Fallback method: List files using SFTP readdir (may miss hidden files)
346
- * @private
347
- */
348
- private async listFilesViaSFTP(path: string, includeHidden: boolean): Promise<FileEntry[]> {
349
- const sftp = await this.getSFTP();
350
-
351
- return new Promise((resolve, reject) => {
352
- sftp.readdir(path, (err, list) => {
353
- if (err) {
354
- logger.error('SFTP readdir failed', { path, error: err.message });
355
- reject(err);
356
- return;
357
- }
358
-
359
- let entries = list.map((item) => ({
360
- name: item.filename,
361
- path: `${path}/${item.filename}`.replace(/\/+/g, '/'),
362
- type: getFileType(item.attrs),
363
- size: item.attrs.size,
364
- permissions: parsePermissions(item.attrs.mode),
365
- owner: {
366
- uid: item.attrs.uid,
367
- gid: item.attrs.gid,
368
- },
369
- timestamps: {
370
- accessed: new Date(item.attrs.atime * 1000).toISOString(),
371
- modified: new Date(item.attrs.mtime * 1000).toISOString(),
372
- },
373
- }));
374
-
375
- // SFTP readdir doesn't return hidden files, so filter if requested
376
- if (!includeHidden) {
377
- entries = entries.filter(e => !e.name.startsWith('.'));
378
- }
379
-
380
- logger.debug('Files listed via SFTP fallback', {
381
- path,
382
- count: entries.length,
383
- method: 'sftp',
384
- note: 'Hidden files may be missing (SFTP limitation)',
385
- });
386
-
387
- resolve(entries);
388
- });
389
- });
390
- }
391
-
392
- /**
393
- * Read file contents from remote machine
394
- */
395
- async readFile(
396
- path: string,
397
- encoding: string = 'utf-8',
398
- maxSize: number = 1048576
399
- ): Promise<{ content: string; size: number; encoding: string }> {
400
- const startTime = Date.now();
401
- logger.fileOperation('read', path, { encoding, maxSize });
402
-
403
- const sftp = await this.getSFTP();
404
-
405
- return new Promise((resolve, reject) => {
406
- // First, get file stats to check size
407
- sftp.stat(path, (err, stats) => {
408
- if (err) {
409
- logger.error('File stat failed', { path, error: err.message });
410
- reject(new Error(`File not found or inaccessible: ${path}`));
411
- return;
412
- }
413
-
414
- logger.debug('File stat retrieved', { path, size: stats.size });
415
-
416
- if (stats.size > maxSize) {
417
- logger.warn('File too large', { path, size: stats.size, maxSize });
418
- reject(new Error(`File too large: ${stats.size} bytes (max: ${maxSize} bytes)`));
419
- return;
420
- }
421
-
422
- // Read file contents
423
- sftp.readFile(path, { encoding: encoding as BufferEncoding }, (err, data) => {
424
- if (err) {
425
- logger.error('File read failed', { path, error: err.message });
426
- reject(new Error(`Failed to read file: ${err.message}`));
427
- return;
428
- }
429
-
430
- const duration = Date.now() - startTime;
431
- logger.debug('File read completed', { path, size: stats.size, duration: `${duration}ms` });
432
-
433
- resolve({
434
- content: data.toString(),
435
- size: stats.size,
436
- encoding,
437
- });
438
- });
439
- });
440
- });
441
- }
442
-
443
- /**
444
- * Write file contents to remote machine
445
- */
446
- async writeFile(
447
- path: string,
448
- content: string,
449
- options: {
450
- encoding?: string;
451
- createDirs?: boolean;
452
- backup?: boolean;
453
- } = {}
454
- ): Promise<{ success: boolean; bytesWritten: number; backupPath?: string }> {
455
- const { encoding = 'utf-8', createDirs = false, backup = false } = options;
456
- const startTime = Date.now();
457
-
458
- logger.fileOperation('write', path, {
459
- contentSize: content.length,
460
- encoding,
461
- createDirs,
462
- backup,
463
- });
464
-
465
- const sftp = await this.getSFTP();
466
-
467
- return new Promise((resolve, reject) => {
468
- const writeOperation = () => {
469
- // Create backup if requested
470
- if (backup) {
471
- const backupPath = `${path}.backup`;
472
- sftp.rename(path, backupPath, (err) => {
473
- if (err && err.message !== 'No such file') {
474
- // Ignore "no such file" error (file doesn't exist yet)
475
- reject(new Error(`Failed to create backup: ${err.message}`));
476
- return;
477
- }
478
-
479
- // Write file
480
- performWrite(backupPath);
481
- });
482
- } else {
483
- performWrite();
484
- }
485
- };
486
-
487
- const performWrite = (backupPath?: string) => {
488
- const buffer = Buffer.from(content, encoding as BufferEncoding);
489
- const tempPath = `${path}.tmp`;
490
-
491
- // Write to temp file first (atomic write)
492
- sftp.writeFile(tempPath, buffer, (err) => {
493
- if (err) {
494
- reject(new Error(`Failed to write file: ${err.message}`));
495
- return;
496
- }
497
-
498
- // Rename temp file to target (atomic operation)
499
- sftp.rename(tempPath, path, (err) => {
500
- if (err) {
501
- logger.error('File rename failed', { tempPath, path, error: err.message });
502
- reject(new Error(`Failed to rename temp file: ${err.message}`));
503
- return;
504
- }
505
-
506
- const duration = Date.now() - startTime;
507
- logger.debug('File write completed', {
508
- path,
509
- bytesWritten: buffer.length,
510
- duration: `${duration}ms`,
511
- backupPath,
512
- });
513
-
514
- resolve({
515
- success: true,
516
- bytesWritten: buffer.length,
517
- backupPath,
518
- });
519
- });
520
- });
521
- };
522
-
523
- // Create parent directories if requested
524
- if (createDirs) {
525
- const dirPath = path.substring(0, path.lastIndexOf('/'));
526
- this.exec(`mkdir -p ${dirPath}`).then(() => {
527
- writeOperation();
528
- }).catch(reject);
529
- } else {
530
- writeOperation();
531
- }
532
- });
533
- }
534
-
535
232
  /**
536
233
  * Wrap command to source shell configuration files
537
234
  * This ensures PATH and other environment variables are properly set
@@ -1,16 +0,0 @@
1
- import { Tool } from '@modelcontextprotocol/sdk/types.js';
2
- import { SSHConnectionManager } from '../utils/ssh-connection.js';
3
- export declare const acpRemoteListFilesTool: Tool;
4
- /**
5
- * Handle the acp_remote_list_files tool invocation
6
- * Lists files and directories at the specified path on the remote machine via SSH
7
- *
8
- * @param args - Tool arguments containing path and recursive flag
9
- * @param sshConnection - SSH connection manager for remote operations
10
- */
11
- export declare function handleAcpRemoteListFiles(args: any, sshConnection: SSHConnectionManager): Promise<{
12
- content: Array<{
13
- type: string;
14
- text: string;
15
- }>;
16
- }>;
@@ -1,16 +0,0 @@
1
- import { Tool } from '@modelcontextprotocol/sdk/types.js';
2
- import { SSHConnectionManager } from '../utils/ssh-connection.js';
3
- export declare const acpRemoteReadFileTool: Tool;
4
- /**
5
- * Handle the acp_remote_read_file tool invocation
6
- * Reads file contents from the remote machine via SSH
7
- *
8
- * @param args - Tool arguments containing path, encoding, and maxSize
9
- * @param sshConnection - SSH connection manager for remote operations
10
- */
11
- export declare function handleAcpRemoteReadFile(args: any, sshConnection: SSHConnectionManager): Promise<{
12
- content: Array<{
13
- type: string;
14
- text: string;
15
- }>;
16
- }>;
@@ -1,16 +0,0 @@
1
- import { Tool } from '@modelcontextprotocol/sdk/types.js';
2
- import { SSHConnectionManager } from '../utils/ssh-connection.js';
3
- export declare const acpRemoteWriteFileTool: Tool;
4
- /**
5
- * Handle the acp_remote_write_file tool invocation
6
- * Writes file contents to the remote machine via SSH
7
- *
8
- * @param args - Tool arguments containing path, content, and options
9
- * @param sshConnection - SSH connection manager for remote operations
10
- */
11
- export declare function handleAcpRemoteWriteFile(args: any, sshConnection: SSHConnectionManager): Promise<{
12
- content: Array<{
13
- type: string;
14
- text: string;
15
- }>;
16
- }>;
@@ -1,88 +0,0 @@
1
- /**
2
- * Comprehensive file entry with metadata
3
- */
4
- export interface FileEntry {
5
- /** Filename (without path) */
6
- name: string;
7
- /** Absolute path to file */
8
- path: string;
9
- /** File type */
10
- type: 'file' | 'directory' | 'symlink' | 'other';
11
- /** File size in bytes */
12
- size: number;
13
- /** File permissions */
14
- permissions: {
15
- /** Octal mode (e.g., 0o644) */
16
- mode: number;
17
- /** Human-readable string (e.g., "rw-r--r--") */
18
- string: string;
19
- /** Owner permissions */
20
- owner: {
21
- read: boolean;
22
- write: boolean;
23
- execute: boolean;
24
- };
25
- /** Group permissions */
26
- group: {
27
- read: boolean;
28
- write: boolean;
29
- execute: boolean;
30
- };
31
- /** Others permissions */
32
- others: {
33
- read: boolean;
34
- write: boolean;
35
- execute: boolean;
36
- };
37
- };
38
- /** File ownership */
39
- owner: {
40
- /** User ID */
41
- uid: number;
42
- /** Group ID */
43
- gid: number;
44
- };
45
- /** File timestamps */
46
- timestamps: {
47
- /** Last access time (ISO 8601) */
48
- accessed: string;
49
- /** Last modification time (ISO 8601) */
50
- modified: string;
51
- };
52
- }
53
- /**
54
- * Convert Unix mode to human-readable permission string
55
- * @param mode - Unix file mode (e.g., 33188 for -rw-r--r--)
56
- * @returns Permission string (e.g., "rw-r--r--")
57
- */
58
- export declare function modeToPermissionString(mode: number): string;
59
- /**
60
- * Parse Unix mode into structured permissions object
61
- * @param mode - Unix file mode
62
- * @returns Structured permissions object
63
- */
64
- export declare function parsePermissions(mode: number): {
65
- mode: number;
66
- string: string;
67
- owner: {
68
- read: boolean;
69
- write: boolean;
70
- execute: boolean;
71
- };
72
- group: {
73
- read: boolean;
74
- write: boolean;
75
- execute: boolean;
76
- };
77
- others: {
78
- read: boolean;
79
- write: boolean;
80
- execute: boolean;
81
- };
82
- };
83
- /**
84
- * Determine file type from SFTP stats
85
- * @param stats - SFTP file stats
86
- * @returns File type string
87
- */
88
- export declare function getFileType(stats: any): 'file' | 'directory' | 'symlink' | 'other';
@@ -1,100 +0,0 @@
1
- import { Tool } from '@modelcontextprotocol/sdk/types.js';
2
- import { SSHConnectionManager } from '../utils/ssh-connection.js';
3
- import { FileEntry } from '../types/file-entry.js';
4
-
5
- export const acpRemoteListFilesTool: Tool = {
6
- name: 'acp_remote_list_files',
7
- description: 'List files and directories in a specified path on the remote machine via SSH. Returns comprehensive metadata including permissions, timestamps, size, and ownership. Includes hidden files by default.',
8
- inputSchema: {
9
- type: 'object',
10
- properties: {
11
- path: {
12
- type: 'string',
13
- description: 'The directory path to list files from',
14
- },
15
- recursive: {
16
- type: 'boolean',
17
- description: 'Whether to list files recursively',
18
- default: false,
19
- },
20
- includeHidden: {
21
- type: 'boolean',
22
- description: 'Whether to include hidden files (starting with .)',
23
- default: true,
24
- },
25
- },
26
- required: ['path'],
27
- },
28
- };
29
-
30
- interface ListFilesArgs {
31
- path: string;
32
- recursive?: boolean;
33
- includeHidden?: boolean;
34
- }
35
-
36
- /**
37
- * Handle the acp_remote_list_files tool invocation
38
- * Lists files and directories at the specified path on the remote machine via SSH
39
- *
40
- * @param args - Tool arguments containing path and recursive flag
41
- * @param sshConnection - SSH connection manager for remote operations
42
- */
43
- export async function handleAcpRemoteListFiles(
44
- args: any,
45
- sshConnection: SSHConnectionManager
46
- ): Promise<{ content: Array<{ type: string; text: string }> }> {
47
- const { path, recursive = false, includeHidden = true } = args as ListFilesArgs;
48
-
49
- try {
50
- const entries = await listRemoteFiles(sshConnection, path, recursive, includeHidden);
51
-
52
- // Format as JSON for structured output
53
- const output = JSON.stringify(entries, null, 2);
54
-
55
- return {
56
- content: [
57
- {
58
- type: 'text',
59
- text: output,
60
- },
61
- ],
62
- };
63
- } catch (error) {
64
- const errorMessage = error instanceof Error ? error.message : String(error);
65
- return {
66
- content: [
67
- {
68
- type: 'text',
69
- text: `Error listing remote files: ${errorMessage}`,
70
- },
71
- ],
72
- };
73
- }
74
- }
75
-
76
- /**
77
- * Recursively list files in a remote directory via SSH
78
- * Returns FileEntry objects with comprehensive metadata
79
- */
80
- async function listRemoteFiles(
81
- ssh: SSHConnectionManager,
82
- dirPath: string,
83
- recursive: boolean,
84
- includeHidden: boolean
85
- ): Promise<FileEntry[]> {
86
- const entries = await ssh.listFiles(dirPath, includeHidden);
87
- const allEntries: FileEntry[] = [...entries];
88
-
89
- // Recursively list subdirectories if requested
90
- if (recursive) {
91
- for (const entry of entries) {
92
- if (entry.type === 'directory') {
93
- const subEntries = await listRemoteFiles(ssh, entry.path, recursive, includeHidden);
94
- allEntries.push(...subEntries);
95
- }
96
- }
97
- }
98
-
99
- return allEntries;
100
- }
@@ -1,94 +0,0 @@
1
- import { Tool } from '@modelcontextprotocol/sdk/types.js';
2
- import { SSHConnectionManager } from '../utils/ssh-connection.js';
3
- import { logger } from '../utils/logger.js';
4
-
5
- export const acpRemoteReadFileTool: Tool = {
6
- name: 'acp_remote_read_file',
7
- description: 'Read file contents from the remote machine via SSH',
8
- inputSchema: {
9
- type: 'object',
10
- properties: {
11
- path: {
12
- type: 'string',
13
- description: 'Absolute path to file',
14
- },
15
- encoding: {
16
- type: 'string',
17
- description: 'File encoding (default: utf-8)',
18
- default: 'utf-8',
19
- enum: ['utf-8', 'ascii', 'base64'],
20
- },
21
- maxSize: {
22
- type: 'number',
23
- description: 'Max file size in bytes (default: 1MB)',
24
- default: 1048576,
25
- },
26
- },
27
- required: ['path'],
28
- },
29
- };
30
-
31
- interface ReadFileArgs {
32
- path: string;
33
- encoding?: string;
34
- maxSize?: number;
35
- }
36
-
37
- interface ReadFileResult {
38
- content: string;
39
- size: number;
40
- encoding: string;
41
- }
42
-
43
- /**
44
- * Handle the acp_remote_read_file tool invocation
45
- * Reads file contents from the remote machine via SSH
46
- *
47
- * @param args - Tool arguments containing path, encoding, and maxSize
48
- * @param sshConnection - SSH connection manager for remote operations
49
- */
50
- export async function handleAcpRemoteReadFile(
51
- args: any,
52
- sshConnection: SSHConnectionManager
53
- ): Promise<{ content: Array<{ type: string; text: string }> }> {
54
- const { path, encoding = 'utf-8', maxSize = 1048576 } = args as ReadFileArgs;
55
-
56
- logger.debug('Reading remote file', { path, encoding, maxSize });
57
-
58
- try {
59
- const result = await sshConnection.readFile(path, encoding, maxSize);
60
-
61
- logger.debug('File read successful', { path, size: result.size });
62
-
63
- const output: ReadFileResult = {
64
- content: result.content,
65
- size: result.size,
66
- encoding: result.encoding,
67
- };
68
-
69
- return {
70
- content: [
71
- {
72
- type: 'text',
73
- text: JSON.stringify(output, null, 2),
74
- },
75
- ],
76
- };
77
- } catch (error) {
78
- const errorMessage = error instanceof Error ? error.message : String(error);
79
- logger.error('File read error', { path, error: errorMessage });
80
- return {
81
- content: [
82
- {
83
- type: 'text',
84
- text: JSON.stringify({
85
- error: errorMessage,
86
- content: '',
87
- size: 0,
88
- encoding,
89
- }, null, 2),
90
- },
91
- ],
92
- };
93
- }
94
- }