@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.
package/dist/server.js CHANGED
@@ -436,7 +436,7 @@ function loadSSHPrivateKey() {
436
436
  // src/tools/acp-remote-list-files.ts
437
437
  var acpRemoteListFilesTool = {
438
438
  name: "acp_remote_list_files",
439
- description: "List files and directories in a specified path on the remote machine via SSH",
439
+ 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.",
440
440
  inputSchema: {
441
441
  type: "object",
442
442
  properties: {
@@ -448,20 +448,26 @@ var acpRemoteListFilesTool = {
448
448
  type: "boolean",
449
449
  description: "Whether to list files recursively",
450
450
  default: false
451
+ },
452
+ includeHidden: {
453
+ type: "boolean",
454
+ description: "Whether to include hidden files (starting with .)",
455
+ default: true
451
456
  }
452
457
  },
453
458
  required: ["path"]
454
459
  }
455
460
  };
456
461
  async function handleAcpRemoteListFiles(args, sshConnection) {
457
- const { path, recursive = false } = args;
462
+ const { path, recursive = false, includeHidden = true } = args;
458
463
  try {
459
- const files = await listRemoteFiles(sshConnection, path, recursive);
464
+ const entries = await listRemoteFiles(sshConnection, path, recursive, includeHidden);
465
+ const output = JSON.stringify(entries, null, 2);
460
466
  return {
461
467
  content: [
462
468
  {
463
469
  type: "text",
464
- text: files.join("\n")
470
+ text: output
465
471
  }
466
472
  ]
467
473
  };
@@ -477,22 +483,18 @@ async function handleAcpRemoteListFiles(args, sshConnection) {
477
483
  };
478
484
  }
479
485
  }
480
- async function listRemoteFiles(ssh, dirPath, recursive) {
481
- const entries = await ssh.listFiles(dirPath);
482
- const files = [];
483
- for (const entry of entries) {
484
- const fullPath = `${dirPath}/${entry.name}`.replace(/\/+/g, "/");
485
- if (entry.isDirectory) {
486
- files.push(`${fullPath}/`);
487
- if (recursive) {
488
- const subFiles = await listRemoteFiles(ssh, fullPath, recursive);
489
- files.push(...subFiles);
486
+ async function listRemoteFiles(ssh, dirPath, recursive, includeHidden) {
487
+ const entries = await ssh.listFiles(dirPath, includeHidden);
488
+ const allEntries = [...entries];
489
+ if (recursive) {
490
+ for (const entry of entries) {
491
+ if (entry.type === "directory") {
492
+ const subEntries = await listRemoteFiles(ssh, entry.path, recursive, includeHidden);
493
+ allEntries.push(...subEntries);
490
494
  }
491
- } else {
492
- files.push(fullPath);
493
495
  }
494
496
  }
495
- return files.sort();
497
+ return allEntries;
496
498
  }
497
499
 
498
500
  // src/utils/logger.ts
@@ -605,7 +607,7 @@ var logger = new Logger();
605
607
  // src/tools/acp-remote-execute-command.ts
606
608
  var acpRemoteExecuteCommandTool = {
607
609
  name: "acp_remote_execute_command",
608
- description: "Execute a shell command on the remote machine via SSH",
610
+ description: "Execute a shell command on the remote machine via SSH. Supports real-time progress streaming if client provides progressToken.",
609
611
  inputSchema: {
610
612
  type: "object",
611
613
  properties: {
@@ -619,17 +621,21 @@ var acpRemoteExecuteCommandTool = {
619
621
  },
620
622
  timeout: {
621
623
  type: "number",
622
- description: "Timeout in seconds (default: 30)",
624
+ description: "Timeout in seconds (default: 30). Ignored if progress streaming is used.",
623
625
  default: 30
624
626
  }
625
627
  },
626
628
  required: ["command"]
627
629
  }
628
630
  };
629
- async function handleAcpRemoteExecuteCommand(args, sshConnection) {
631
+ async function handleAcpRemoteExecuteCommand(args, sshConnection, extra, server) {
630
632
  const { command, cwd, timeout = 30 } = args;
631
- logger.debug("Executing remote command", { command, cwd, timeout });
633
+ const progressToken = extra?._meta?.progressToken;
634
+ logger.debug("Executing remote command", { command, cwd, timeout, hasProgressToken: !!progressToken });
632
635
  try {
636
+ if (progressToken && server) {
637
+ return await executeWithProgress(command, cwd, sshConnection, progressToken, server);
638
+ }
633
639
  const fullCommand = cwd ? `cd ${cwd} && ${command}` : command;
634
640
  const result = await sshConnection.execWithTimeout(fullCommand, timeout);
635
641
  logger.debug("Command execution result", {
@@ -670,6 +676,75 @@ async function handleAcpRemoteExecuteCommand(args, sshConnection) {
670
676
  };
671
677
  }
672
678
  }
679
+ async function executeWithProgress(command, cwd, sshConnection, progressToken, server) {
680
+ logger.debug("Starting streaming execution", { command, cwd, progressToken });
681
+ const { stream, stderr: stderrStream, exitCode } = await sshConnection.execStream(command, cwd);
682
+ let stdout = "";
683
+ let stderr = "";
684
+ let bytesReceived = 0;
685
+ let lastProgressTime = 0;
686
+ const MIN_PROGRESS_INTERVAL = 100;
687
+ stream.on("data", (chunk) => {
688
+ const text = chunk.toString();
689
+ stdout += text;
690
+ bytesReceived += chunk.length;
691
+ const now = Date.now();
692
+ if (now - lastProgressTime >= MIN_PROGRESS_INTERVAL) {
693
+ try {
694
+ server.notification({
695
+ method: "notifications/progress",
696
+ params: {
697
+ progressToken,
698
+ progress: bytesReceived,
699
+ total: void 0,
700
+ // Unknown total for streaming
701
+ message: text
702
+ }
703
+ });
704
+ lastProgressTime = now;
705
+ logger.debug("Progress notification sent", {
706
+ progressToken,
707
+ bytes: bytesReceived,
708
+ chunkSize: chunk.length
709
+ });
710
+ } catch (error) {
711
+ logger.warn("Failed to send progress notification", {
712
+ error: error instanceof Error ? error.message : String(error)
713
+ });
714
+ }
715
+ }
716
+ });
717
+ stderrStream.on("data", (chunk) => {
718
+ stderr += chunk.toString();
719
+ });
720
+ stream.on("error", (error) => {
721
+ logger.error("Stream error during execution", {
722
+ command,
723
+ error: error.message
724
+ });
725
+ });
726
+ const finalExitCode = await exitCode;
727
+ logger.debug("Streaming execution completed", {
728
+ command,
729
+ exitCode: finalExitCode,
730
+ stdoutBytes: stdout.length,
731
+ stderrBytes: stderr.length
732
+ });
733
+ const output = {
734
+ stdout,
735
+ stderr,
736
+ exitCode: finalExitCode,
737
+ timedOut: false,
738
+ streamed: true
739
+ // Indicate this was streamed
740
+ };
741
+ return {
742
+ content: [{
743
+ type: "text",
744
+ text: JSON.stringify(output, null, 2)
745
+ }]
746
+ };
747
+ }
673
748
 
674
749
  // src/tools/acp-remote-read-file.ts
675
750
  var acpRemoteReadFileTool = {
@@ -812,6 +887,54 @@ async function handleAcpRemoteWriteFile(args, sshConnection) {
812
887
 
813
888
  // src/utils/ssh-connection.ts
814
889
  import { Client } from "ssh2";
890
+
891
+ // src/types/file-entry.ts
892
+ function modeToPermissionString(mode) {
893
+ const perms = [
894
+ mode & 256 ? "r" : "-",
895
+ mode & 128 ? "w" : "-",
896
+ mode & 64 ? "x" : "-",
897
+ mode & 32 ? "r" : "-",
898
+ mode & 16 ? "w" : "-",
899
+ mode & 8 ? "x" : "-",
900
+ mode & 4 ? "r" : "-",
901
+ mode & 2 ? "w" : "-",
902
+ mode & 1 ? "x" : "-"
903
+ ];
904
+ return perms.join("");
905
+ }
906
+ function parsePermissions(mode) {
907
+ return {
908
+ mode,
909
+ string: modeToPermissionString(mode),
910
+ owner: {
911
+ read: (mode & 256) !== 0,
912
+ write: (mode & 128) !== 0,
913
+ execute: (mode & 64) !== 0
914
+ },
915
+ group: {
916
+ read: (mode & 32) !== 0,
917
+ write: (mode & 16) !== 0,
918
+ execute: (mode & 8) !== 0
919
+ },
920
+ others: {
921
+ read: (mode & 4) !== 0,
922
+ write: (mode & 2) !== 0,
923
+ execute: (mode & 1) !== 0
924
+ }
925
+ };
926
+ }
927
+ function getFileType(stats) {
928
+ if (stats.isDirectory())
929
+ return "directory";
930
+ if (stats.isFile())
931
+ return "file";
932
+ if (stats.isSymbolicLink())
933
+ return "symlink";
934
+ return "other";
935
+ }
936
+
937
+ // src/utils/ssh-connection.ts
815
938
  var SSHConnectionManager = class {
816
939
  client;
817
940
  config;
@@ -937,6 +1060,57 @@ var SSHConnectionManager = class {
937
1060
  throw error;
938
1061
  }
939
1062
  }
1063
+ /**
1064
+ * Execute a command on the remote server with streaming output
1065
+ * Returns streams instead of buffered output for real-time progress
1066
+ *
1067
+ * @param command - Shell command to execute
1068
+ * @param cwd - Optional working directory
1069
+ * @returns Object with stdout stream, stderr stream, and exit code promise
1070
+ */
1071
+ async execStream(command, cwd) {
1072
+ if (!this.connected) {
1073
+ await this.connect();
1074
+ }
1075
+ const fullCommand = cwd ? `cd "${cwd}" && ${command}` : command;
1076
+ const startTime = Date.now();
1077
+ logger.sshCommand(fullCommand, cwd);
1078
+ return new Promise((resolve, reject) => {
1079
+ this.client.exec(fullCommand, (err, stream) => {
1080
+ if (err) {
1081
+ logger.error("SSH exec failed", {
1082
+ command: fullCommand,
1083
+ error: err.message
1084
+ });
1085
+ reject(err);
1086
+ return;
1087
+ }
1088
+ logger.debug("SSH stream started", { command: fullCommand });
1089
+ const exitCodePromise = new Promise((resolveExit) => {
1090
+ stream.on("close", (code) => {
1091
+ const duration = Date.now() - startTime;
1092
+ logger.debug("SSH stream closed", {
1093
+ command: fullCommand,
1094
+ exitCode: code,
1095
+ duration: `${duration}ms`
1096
+ });
1097
+ resolveExit(code);
1098
+ });
1099
+ });
1100
+ stream.on("error", (error) => {
1101
+ logger.error("SSH stream error", {
1102
+ command: fullCommand,
1103
+ error: error.message
1104
+ });
1105
+ });
1106
+ resolve({
1107
+ stream,
1108
+ stderr: stream.stderr,
1109
+ exitCode: exitCodePromise
1110
+ });
1111
+ });
1112
+ });
1113
+ }
940
1114
  /**
941
1115
  * Get SFTP wrapper for file operations
942
1116
  */
@@ -955,21 +1129,118 @@ var SSHConnectionManager = class {
955
1129
  });
956
1130
  }
957
1131
  /**
958
- * List files in a directory using SFTP
1132
+ * List files in a directory with comprehensive metadata
1133
+ * Uses hybrid approach: shell ls for filenames (includes hidden), SFTP stat for metadata
1134
+ *
1135
+ * @param path - Directory path to list
1136
+ * @param includeHidden - Whether to include hidden files (default: true)
1137
+ * @returns Array of FileEntry objects with complete metadata
959
1138
  */
960
- async listFiles(path) {
1139
+ async listFiles(path, includeHidden = true) {
1140
+ const startTime = Date.now();
1141
+ logger.debug("Listing files", { path, includeHidden });
1142
+ try {
1143
+ const lsFlag = includeHidden ? "-A" : "";
1144
+ const command = `ls ${lsFlag} -1 "${path}" 2>/dev/null`;
1145
+ const result = await this.execWithTimeout(command, 10);
1146
+ if (result.exitCode !== 0) {
1147
+ throw new Error(`ls command failed: ${result.stderr}`);
1148
+ }
1149
+ const filenames = result.stdout.split("\n").map((f) => f.trim()).filter((f) => f !== "" && f !== "." && f !== "..");
1150
+ logger.debug("Filenames retrieved via shell", {
1151
+ path,
1152
+ count: filenames.length,
1153
+ method: "shell"
1154
+ });
1155
+ const sftp = await this.getSFTP();
1156
+ const entries = [];
1157
+ for (const filename of filenames) {
1158
+ const fullPath = `${path}/${filename}`.replace(/\/+/g, "/");
1159
+ try {
1160
+ const stats = await new Promise((resolve, reject) => {
1161
+ sftp.stat(fullPath, (err, stats2) => {
1162
+ if (err)
1163
+ reject(err);
1164
+ else
1165
+ resolve(stats2);
1166
+ });
1167
+ });
1168
+ entries.push({
1169
+ name: filename,
1170
+ path: fullPath,
1171
+ type: getFileType(stats),
1172
+ size: stats.size,
1173
+ permissions: parsePermissions(stats.mode),
1174
+ owner: {
1175
+ uid: stats.uid,
1176
+ gid: stats.gid
1177
+ },
1178
+ timestamps: {
1179
+ accessed: new Date(stats.atime * 1e3).toISOString(),
1180
+ modified: new Date(stats.mtime * 1e3).toISOString()
1181
+ }
1182
+ });
1183
+ } catch (error) {
1184
+ logger.warn("Failed to stat file, skipping", {
1185
+ path: fullPath,
1186
+ error: error instanceof Error ? error.message : String(error)
1187
+ });
1188
+ }
1189
+ }
1190
+ const duration = Date.now() - startTime;
1191
+ logger.debug("Files listed successfully", {
1192
+ path,
1193
+ count: entries.length,
1194
+ duration: `${duration}ms`,
1195
+ method: "hybrid"
1196
+ });
1197
+ return entries;
1198
+ } catch (error) {
1199
+ logger.warn("Shell ls command failed, falling back to SFTP readdir", {
1200
+ path,
1201
+ error: error instanceof Error ? error.message : String(error)
1202
+ });
1203
+ return this.listFilesViaSFTP(path, includeHidden);
1204
+ }
1205
+ }
1206
+ /**
1207
+ * Fallback method: List files using SFTP readdir (may miss hidden files)
1208
+ * @private
1209
+ */
1210
+ async listFilesViaSFTP(path, includeHidden) {
961
1211
  const sftp = await this.getSFTP();
962
1212
  return new Promise((resolve, reject) => {
963
1213
  sftp.readdir(path, (err, list) => {
964
1214
  if (err) {
1215
+ logger.error("SFTP readdir failed", { path, error: err.message });
965
1216
  reject(err);
966
1217
  return;
967
1218
  }
968
- const files = list.map((item) => ({
1219
+ let entries = list.map((item) => ({
969
1220
  name: item.filename,
970
- isDirectory: item.attrs.isDirectory()
1221
+ path: `${path}/${item.filename}`.replace(/\/+/g, "/"),
1222
+ type: getFileType(item.attrs),
1223
+ size: item.attrs.size,
1224
+ permissions: parsePermissions(item.attrs.mode),
1225
+ owner: {
1226
+ uid: item.attrs.uid,
1227
+ gid: item.attrs.gid
1228
+ },
1229
+ timestamps: {
1230
+ accessed: new Date(item.attrs.atime * 1e3).toISOString(),
1231
+ modified: new Date(item.attrs.mtime * 1e3).toISOString()
1232
+ }
971
1233
  }));
972
- resolve(files);
1234
+ if (!includeHidden) {
1235
+ entries = entries.filter((e) => !e.name.startsWith("."));
1236
+ }
1237
+ logger.debug("Files listed via SFTP fallback", {
1238
+ path,
1239
+ count: entries.length,
1240
+ method: "sftp",
1241
+ note: "Hidden files may be missing (SFTP limitation)"
1242
+ });
1243
+ resolve(entries);
973
1244
  });
974
1245
  });
975
1246
  }
@@ -1126,7 +1397,7 @@ async function main() {
1126
1397
  logger.debug(`Returning ${tools.length} tools`, { tools: tools.map((t) => t.name) });
1127
1398
  return { tools };
1128
1399
  });
1129
- server.setRequestHandler(CallToolRequestSchema, async (request) => {
1400
+ server.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
1130
1401
  const startTime = Date.now();
1131
1402
  logger.toolInvoked(request.params.name, request.params.arguments);
1132
1403
  try {
@@ -1134,7 +1405,7 @@ async function main() {
1134
1405
  if (request.params.name === "acp_remote_list_files") {
1135
1406
  result = await handleAcpRemoteListFiles(request.params.arguments, sshConnection);
1136
1407
  } else if (request.params.name === "acp_remote_execute_command") {
1137
- result = await handleAcpRemoteExecuteCommand(request.params.arguments, sshConnection);
1408
+ result = await handleAcpRemoteExecuteCommand(request.params.arguments, sshConnection, extra, server);
1138
1409
  } else if (request.params.name === "acp_remote_read_file") {
1139
1410
  result = await handleAcpRemoteReadFile(request.params.arguments, sshConnection);
1140
1411
  } else if (request.params.name === "acp_remote_write_file") {