@prmichaelsen/acp-mcp 0.5.1 → 0.7.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.
@@ -8,7 +8,7 @@ import {
8
8
  // src/tools/acp-remote-list-files.ts
9
9
  var acpRemoteListFilesTool = {
10
10
  name: "acp_remote_list_files",
11
- description: "List files and directories in a specified path on the remote machine via SSH",
11
+ 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.",
12
12
  inputSchema: {
13
13
  type: "object",
14
14
  properties: {
@@ -20,20 +20,26 @@ var acpRemoteListFilesTool = {
20
20
  type: "boolean",
21
21
  description: "Whether to list files recursively",
22
22
  default: false
23
+ },
24
+ includeHidden: {
25
+ type: "boolean",
26
+ description: "Whether to include hidden files (starting with .)",
27
+ default: true
23
28
  }
24
29
  },
25
30
  required: ["path"]
26
31
  }
27
32
  };
28
33
  async function handleAcpRemoteListFiles(args, sshConnection) {
29
- const { path, recursive = false } = args;
34
+ const { path, recursive = false, includeHidden = true } = args;
30
35
  try {
31
- const files = await listRemoteFiles(sshConnection, path, recursive);
36
+ const entries = await listRemoteFiles(sshConnection, path, recursive, includeHidden);
37
+ const output = JSON.stringify(entries, null, 2);
32
38
  return {
33
39
  content: [
34
40
  {
35
41
  type: "text",
36
- text: files.join("\n")
42
+ text: output
37
43
  }
38
44
  ]
39
45
  };
@@ -49,22 +55,18 @@ async function handleAcpRemoteListFiles(args, sshConnection) {
49
55
  };
50
56
  }
51
57
  }
52
- async function listRemoteFiles(ssh, dirPath, recursive) {
53
- const entries = await ssh.listFiles(dirPath);
54
- const files = [];
55
- for (const entry of entries) {
56
- const fullPath = `${dirPath}/${entry.name}`.replace(/\/+/g, "/");
57
- if (entry.isDirectory) {
58
- files.push(`${fullPath}/`);
59
- if (recursive) {
60
- const subFiles = await listRemoteFiles(ssh, fullPath, recursive);
61
- files.push(...subFiles);
58
+ async function listRemoteFiles(ssh, dirPath, recursive, includeHidden) {
59
+ const entries = await ssh.listFiles(dirPath, includeHidden);
60
+ const allEntries = [...entries];
61
+ if (recursive) {
62
+ for (const entry of entries) {
63
+ if (entry.type === "directory") {
64
+ const subEntries = await listRemoteFiles(ssh, entry.path, recursive, includeHidden);
65
+ allEntries.push(...subEntries);
62
66
  }
63
- } else {
64
- files.push(fullPath);
65
67
  }
66
68
  }
67
- return files.sort();
69
+ return allEntries;
68
70
  }
69
71
 
70
72
  // src/utils/logger.ts
@@ -177,7 +179,7 @@ var logger = new Logger();
177
179
  // src/tools/acp-remote-execute-command.ts
178
180
  var acpRemoteExecuteCommandTool = {
179
181
  name: "acp_remote_execute_command",
180
- description: "Execute a shell command on the remote machine via SSH",
182
+ description: "Execute a shell command on the remote machine via SSH. Supports real-time progress streaming if client provides progressToken.",
181
183
  inputSchema: {
182
184
  type: "object",
183
185
  properties: {
@@ -191,17 +193,21 @@ var acpRemoteExecuteCommandTool = {
191
193
  },
192
194
  timeout: {
193
195
  type: "number",
194
- description: "Timeout in seconds (default: 30)",
196
+ description: "Timeout in seconds (default: 30). Ignored if progress streaming is used.",
195
197
  default: 30
196
198
  }
197
199
  },
198
200
  required: ["command"]
199
201
  }
200
202
  };
201
- async function handleAcpRemoteExecuteCommand(args, sshConnection) {
203
+ async function handleAcpRemoteExecuteCommand(args, sshConnection, extra, server) {
202
204
  const { command, cwd, timeout = 30 } = args;
203
- logger.debug("Executing remote command", { command, cwd, timeout });
205
+ const progressToken = extra?._meta?.progressToken;
206
+ logger.debug("Executing remote command", { command, cwd, timeout, hasProgressToken: !!progressToken });
204
207
  try {
208
+ if (progressToken && server) {
209
+ return await executeWithProgress(command, cwd, sshConnection, progressToken, server);
210
+ }
205
211
  const fullCommand = cwd ? `cd ${cwd} && ${command}` : command;
206
212
  const result = await sshConnection.execWithTimeout(fullCommand, timeout);
207
213
  logger.debug("Command execution result", {
@@ -242,6 +248,75 @@ async function handleAcpRemoteExecuteCommand(args, sshConnection) {
242
248
  };
243
249
  }
244
250
  }
251
+ async function executeWithProgress(command, cwd, sshConnection, progressToken, server) {
252
+ logger.debug("Starting streaming execution", { command, cwd, progressToken });
253
+ const { stream, stderr: stderrStream, exitCode } = await sshConnection.execStream(command, cwd);
254
+ let stdout = "";
255
+ let stderr = "";
256
+ let bytesReceived = 0;
257
+ let lastProgressTime = 0;
258
+ const MIN_PROGRESS_INTERVAL = 100;
259
+ stream.on("data", (chunk) => {
260
+ const text = chunk.toString();
261
+ stdout += text;
262
+ bytesReceived += chunk.length;
263
+ const now = Date.now();
264
+ if (now - lastProgressTime >= MIN_PROGRESS_INTERVAL) {
265
+ try {
266
+ server.notification({
267
+ method: "notifications/progress",
268
+ params: {
269
+ progressToken,
270
+ progress: bytesReceived,
271
+ total: void 0,
272
+ // Unknown total for streaming
273
+ message: text
274
+ }
275
+ });
276
+ lastProgressTime = now;
277
+ logger.debug("Progress notification sent", {
278
+ progressToken,
279
+ bytes: bytesReceived,
280
+ chunkSize: chunk.length
281
+ });
282
+ } catch (error) {
283
+ logger.warn("Failed to send progress notification", {
284
+ error: error instanceof Error ? error.message : String(error)
285
+ });
286
+ }
287
+ }
288
+ });
289
+ stderrStream.on("data", (chunk) => {
290
+ stderr += chunk.toString();
291
+ });
292
+ stream.on("error", (error) => {
293
+ logger.error("Stream error during execution", {
294
+ command,
295
+ error: error.message
296
+ });
297
+ });
298
+ const finalExitCode = await exitCode;
299
+ logger.debug("Streaming execution completed", {
300
+ command,
301
+ exitCode: finalExitCode,
302
+ stdoutBytes: stdout.length,
303
+ stderrBytes: stderr.length
304
+ });
305
+ const output = {
306
+ stdout,
307
+ stderr,
308
+ exitCode: finalExitCode,
309
+ timedOut: false,
310
+ streamed: true
311
+ // Indicate this was streamed
312
+ };
313
+ return {
314
+ content: [{
315
+ type: "text",
316
+ text: JSON.stringify(output, null, 2)
317
+ }]
318
+ };
319
+ }
245
320
 
246
321
  // src/tools/acp-remote-read-file.ts
247
322
  var acpRemoteReadFileTool = {
@@ -384,6 +459,54 @@ async function handleAcpRemoteWriteFile(args, sshConnection) {
384
459
 
385
460
  // src/utils/ssh-connection.ts
386
461
  import { Client } from "ssh2";
462
+
463
+ // src/types/file-entry.ts
464
+ function modeToPermissionString(mode) {
465
+ const perms = [
466
+ mode & 256 ? "r" : "-",
467
+ mode & 128 ? "w" : "-",
468
+ mode & 64 ? "x" : "-",
469
+ mode & 32 ? "r" : "-",
470
+ mode & 16 ? "w" : "-",
471
+ mode & 8 ? "x" : "-",
472
+ mode & 4 ? "r" : "-",
473
+ mode & 2 ? "w" : "-",
474
+ mode & 1 ? "x" : "-"
475
+ ];
476
+ return perms.join("");
477
+ }
478
+ function parsePermissions(mode) {
479
+ return {
480
+ mode,
481
+ string: modeToPermissionString(mode),
482
+ owner: {
483
+ read: (mode & 256) !== 0,
484
+ write: (mode & 128) !== 0,
485
+ execute: (mode & 64) !== 0
486
+ },
487
+ group: {
488
+ read: (mode & 32) !== 0,
489
+ write: (mode & 16) !== 0,
490
+ execute: (mode & 8) !== 0
491
+ },
492
+ others: {
493
+ read: (mode & 4) !== 0,
494
+ write: (mode & 2) !== 0,
495
+ execute: (mode & 1) !== 0
496
+ }
497
+ };
498
+ }
499
+ function getFileType(stats) {
500
+ if (stats.isDirectory())
501
+ return "directory";
502
+ if (stats.isFile())
503
+ return "file";
504
+ if (stats.isSymbolicLink())
505
+ return "symlink";
506
+ return "other";
507
+ }
508
+
509
+ // src/utils/ssh-connection.ts
387
510
  var SSHConnectionManager = class {
388
511
  client;
389
512
  config;
@@ -509,6 +632,57 @@ var SSHConnectionManager = class {
509
632
  throw error;
510
633
  }
511
634
  }
635
+ /**
636
+ * Execute a command on the remote server with streaming output
637
+ * Returns streams instead of buffered output for real-time progress
638
+ *
639
+ * @param command - Shell command to execute
640
+ * @param cwd - Optional working directory
641
+ * @returns Object with stdout stream, stderr stream, and exit code promise
642
+ */
643
+ async execStream(command, cwd) {
644
+ if (!this.connected) {
645
+ await this.connect();
646
+ }
647
+ const fullCommand = cwd ? `cd "${cwd}" && ${command}` : command;
648
+ const startTime = Date.now();
649
+ logger.sshCommand(fullCommand, cwd);
650
+ return new Promise((resolve, reject) => {
651
+ this.client.exec(fullCommand, (err, stream) => {
652
+ if (err) {
653
+ logger.error("SSH exec failed", {
654
+ command: fullCommand,
655
+ error: err.message
656
+ });
657
+ reject(err);
658
+ return;
659
+ }
660
+ logger.debug("SSH stream started", { command: fullCommand });
661
+ const exitCodePromise = new Promise((resolveExit) => {
662
+ stream.on("close", (code) => {
663
+ const duration = Date.now() - startTime;
664
+ logger.debug("SSH stream closed", {
665
+ command: fullCommand,
666
+ exitCode: code,
667
+ duration: `${duration}ms`
668
+ });
669
+ resolveExit(code);
670
+ });
671
+ });
672
+ stream.on("error", (error) => {
673
+ logger.error("SSH stream error", {
674
+ command: fullCommand,
675
+ error: error.message
676
+ });
677
+ });
678
+ resolve({
679
+ stream,
680
+ stderr: stream.stderr,
681
+ exitCode: exitCodePromise
682
+ });
683
+ });
684
+ });
685
+ }
512
686
  /**
513
687
  * Get SFTP wrapper for file operations
514
688
  */
@@ -527,21 +701,118 @@ var SSHConnectionManager = class {
527
701
  });
528
702
  }
529
703
  /**
530
- * List files in a directory using SFTP
704
+ * List files in a directory with comprehensive metadata
705
+ * Uses hybrid approach: shell ls for filenames (includes hidden), SFTP stat for metadata
706
+ *
707
+ * @param path - Directory path to list
708
+ * @param includeHidden - Whether to include hidden files (default: true)
709
+ * @returns Array of FileEntry objects with complete metadata
531
710
  */
532
- async listFiles(path) {
711
+ async listFiles(path, includeHidden = true) {
712
+ const startTime = Date.now();
713
+ logger.debug("Listing files", { path, includeHidden });
714
+ try {
715
+ const lsFlag = includeHidden ? "-A" : "";
716
+ const command = `ls ${lsFlag} -1 "${path}" 2>/dev/null`;
717
+ const result = await this.execWithTimeout(command, 10);
718
+ if (result.exitCode !== 0) {
719
+ throw new Error(`ls command failed: ${result.stderr}`);
720
+ }
721
+ const filenames = result.stdout.split("\n").map((f) => f.trim()).filter((f) => f !== "" && f !== "." && f !== "..");
722
+ logger.debug("Filenames retrieved via shell", {
723
+ path,
724
+ count: filenames.length,
725
+ method: "shell"
726
+ });
727
+ const sftp = await this.getSFTP();
728
+ const entries = [];
729
+ for (const filename of filenames) {
730
+ const fullPath = `${path}/${filename}`.replace(/\/+/g, "/");
731
+ try {
732
+ const stats = await new Promise((resolve, reject) => {
733
+ sftp.stat(fullPath, (err, stats2) => {
734
+ if (err)
735
+ reject(err);
736
+ else
737
+ resolve(stats2);
738
+ });
739
+ });
740
+ entries.push({
741
+ name: filename,
742
+ path: fullPath,
743
+ type: getFileType(stats),
744
+ size: stats.size,
745
+ permissions: parsePermissions(stats.mode),
746
+ owner: {
747
+ uid: stats.uid,
748
+ gid: stats.gid
749
+ },
750
+ timestamps: {
751
+ accessed: new Date(stats.atime * 1e3).toISOString(),
752
+ modified: new Date(stats.mtime * 1e3).toISOString()
753
+ }
754
+ });
755
+ } catch (error) {
756
+ logger.warn("Failed to stat file, skipping", {
757
+ path: fullPath,
758
+ error: error instanceof Error ? error.message : String(error)
759
+ });
760
+ }
761
+ }
762
+ const duration = Date.now() - startTime;
763
+ logger.debug("Files listed successfully", {
764
+ path,
765
+ count: entries.length,
766
+ duration: `${duration}ms`,
767
+ method: "hybrid"
768
+ });
769
+ return entries;
770
+ } catch (error) {
771
+ logger.warn("Shell ls command failed, falling back to SFTP readdir", {
772
+ path,
773
+ error: error instanceof Error ? error.message : String(error)
774
+ });
775
+ return this.listFilesViaSFTP(path, includeHidden);
776
+ }
777
+ }
778
+ /**
779
+ * Fallback method: List files using SFTP readdir (may miss hidden files)
780
+ * @private
781
+ */
782
+ async listFilesViaSFTP(path, includeHidden) {
533
783
  const sftp = await this.getSFTP();
534
784
  return new Promise((resolve, reject) => {
535
785
  sftp.readdir(path, (err, list) => {
536
786
  if (err) {
787
+ logger.error("SFTP readdir failed", { path, error: err.message });
537
788
  reject(err);
538
789
  return;
539
790
  }
540
- const files = list.map((item) => ({
791
+ let entries = list.map((item) => ({
541
792
  name: item.filename,
542
- isDirectory: item.attrs.isDirectory()
793
+ path: `${path}/${item.filename}`.replace(/\/+/g, "/"),
794
+ type: getFileType(item.attrs),
795
+ size: item.attrs.size,
796
+ permissions: parsePermissions(item.attrs.mode),
797
+ owner: {
798
+ uid: item.attrs.uid,
799
+ gid: item.attrs.gid
800
+ },
801
+ timestamps: {
802
+ accessed: new Date(item.attrs.atime * 1e3).toISOString(),
803
+ modified: new Date(item.attrs.mtime * 1e3).toISOString()
804
+ }
543
805
  }));
544
- resolve(files);
806
+ if (!includeHidden) {
807
+ entries = entries.filter((e) => !e.name.startsWith("."));
808
+ }
809
+ logger.debug("Files listed via SFTP fallback", {
810
+ path,
811
+ count: entries.length,
812
+ method: "sftp",
813
+ note: "Hidden files may be missing (SFTP limitation)"
814
+ });
815
+ resolve(entries);
545
816
  });
546
817
  });
547
818
  }
@@ -698,7 +969,7 @@ async function createServer(serverConfig) {
698
969
  logger.debug(`Returning ${tools.length} tools`, { tools: tools.map((t) => t.name), userId: serverConfig.userId });
699
970
  return { tools };
700
971
  });
701
- server.setRequestHandler(CallToolRequestSchema, async (request) => {
972
+ server.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
702
973
  const startTime = Date.now();
703
974
  logger.toolInvoked(request.params.name, request.params.arguments, serverConfig.userId);
704
975
  try {
@@ -706,7 +977,7 @@ async function createServer(serverConfig) {
706
977
  if (request.params.name === "acp_remote_list_files") {
707
978
  result = await handleAcpRemoteListFiles(request.params.arguments, sshConnection);
708
979
  } else if (request.params.name === "acp_remote_execute_command") {
709
- result = await handleAcpRemoteExecuteCommand(request.params.arguments, sshConnection);
980
+ result = await handleAcpRemoteExecuteCommand(request.params.arguments, sshConnection, extra, server);
710
981
  } else if (request.params.name === "acp_remote_read_file") {
711
982
  result = await handleAcpRemoteReadFile(request.params.arguments, sshConnection);
712
983
  } else if (request.params.name === "acp_remote_write_file") {