@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/CHANGELOG.md +66 -0
- package/README.md +15 -6
- package/agent/design/local.progress-streaming.md +940 -0
- package/agent/milestones/milestone-4-progress-streaming-server.md +84 -0
- package/agent/milestones/milestone-5-progress-streaming-wrapper.md +71 -0
- package/agent/milestones/milestone-6-progress-streaming-client.md +79 -0
- package/agent/progress.yaml +145 -16
- package/agent/tasks/milestone-4-progress-streaming-server/task-6-add-ssh-stream-execution.md +149 -0
- package/agent/tasks/milestone-4-progress-streaming-server/task-7-implement-progress-streaming.md +191 -0
- package/agent/tasks/milestone-4-progress-streaming-server/task-8-update-server-handlers.md +109 -0
- package/agent/tasks/milestone-4-progress-streaming-server/task-9-testing-documentation.md +192 -0
- package/agent/tasks/task-5-fix-incomplete-directory-listings.md +170 -0
- package/dist/server-factory.js +299 -28
- package/dist/server-factory.js.map +4 -4
- package/dist/server.js +299 -28
- package/dist/server.js.map +4 -4
- package/dist/tools/acp-remote-execute-command.d.ts +4 -1
- package/dist/types/file-entry.d.ts +88 -0
- package/dist/utils/ssh-connection.d.ts +26 -5
- package/package.json +1 -1
- package/src/server-factory.ts +3 -2
- package/src/server.ts +3 -2
- package/src/tools/acp-remote-execute-command.ts +116 -7
- package/src/tools/acp-remote-list-files.ts +27 -21
- package/src/types/file-entry.ts +123 -0
- package/src/utils/ssh-connection.ts +189 -5
package/dist/server-factory.js
CHANGED
|
@@ -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
|
|
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:
|
|
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
|
|
55
|
-
|
|
56
|
-
const
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
791
|
+
let entries = list.map((item) => ({
|
|
541
792
|
name: item.filename,
|
|
542
|
-
|
|
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
|
-
|
|
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") {
|