@prmichaelsen/acp-mcp 0.6.0 → 0.7.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/CHANGELOG.md +49 -0
- package/README.md +15 -3
- 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 +138 -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/dist/server-factory.js +145 -8
- package/dist/server-factory.js.map +2 -2
- package/dist/server.js +145 -8
- package/dist/server.js.map +2 -2
- package/dist/tools/acp-remote-execute-command.d.ts +4 -1
- package/dist/utils/ssh-connection.d.ts +22 -0
- 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/utils/ssh-connection.ts +86 -2
|
@@ -4,7 +4,7 @@ import { logger } from '../utils/logger.js';
|
|
|
4
4
|
|
|
5
5
|
export const acpRemoteExecuteCommandTool: Tool = {
|
|
6
6
|
name: 'acp_remote_execute_command',
|
|
7
|
-
description: 'Execute a shell command on the remote machine via SSH',
|
|
7
|
+
description: 'Execute a shell command on the remote machine via SSH. Supports real-time progress streaming if client provides progressToken.',
|
|
8
8
|
inputSchema: {
|
|
9
9
|
type: 'object',
|
|
10
10
|
properties: {
|
|
@@ -18,7 +18,7 @@ export const acpRemoteExecuteCommandTool: Tool = {
|
|
|
18
18
|
},
|
|
19
19
|
timeout: {
|
|
20
20
|
type: 'number',
|
|
21
|
-
description: 'Timeout in seconds (default: 30)',
|
|
21
|
+
description: 'Timeout in seconds (default: 30). Ignored if progress streaming is used.',
|
|
22
22
|
default: 30,
|
|
23
23
|
},
|
|
24
24
|
},
|
|
@@ -37,27 +37,38 @@ interface ExecuteCommandResult {
|
|
|
37
37
|
stderr: string;
|
|
38
38
|
exitCode: number;
|
|
39
39
|
timedOut: boolean;
|
|
40
|
+
streamed?: boolean;
|
|
40
41
|
}
|
|
41
42
|
|
|
42
43
|
/**
|
|
43
44
|
* Handle the acp_remote_execute_command tool invocation
|
|
44
45
|
* Executes a shell command on the remote machine via SSH
|
|
46
|
+
* Supports progress streaming when progressToken is provided
|
|
45
47
|
*
|
|
46
48
|
* @param args - Tool arguments containing command, cwd, and timeout
|
|
47
49
|
* @param sshConnection - SSH connection manager for remote operations
|
|
50
|
+
* @param extra - Optional extra parameters including progressToken
|
|
51
|
+
* @param server - Server instance for sending progress notifications (optional)
|
|
48
52
|
*/
|
|
49
53
|
export async function handleAcpRemoteExecuteCommand(
|
|
50
54
|
args: any,
|
|
51
|
-
sshConnection: SSHConnectionManager
|
|
55
|
+
sshConnection: SSHConnectionManager,
|
|
56
|
+
extra?: any,
|
|
57
|
+
server?: any
|
|
52
58
|
): Promise<{ content: Array<{ type: string; text: string }> }> {
|
|
53
59
|
const { command, cwd, timeout = 30 } = args as ExecuteCommandArgs;
|
|
60
|
+
const progressToken = extra?._meta?.progressToken;
|
|
54
61
|
|
|
55
|
-
logger.debug('Executing remote command', { command, cwd, timeout });
|
|
62
|
+
logger.debug('Executing remote command', { command, cwd, timeout, hasProgressToken: !!progressToken });
|
|
56
63
|
|
|
57
64
|
try {
|
|
58
|
-
//
|
|
59
|
-
|
|
65
|
+
// If progress token provided and server available, use streaming
|
|
66
|
+
if (progressToken && server) {
|
|
67
|
+
return await executeWithProgress(command, cwd, sshConnection, progressToken, server);
|
|
68
|
+
}
|
|
60
69
|
|
|
70
|
+
// Otherwise, use existing timeout-based execution (fallback)
|
|
71
|
+
const fullCommand = cwd ? `cd ${cwd} && ${command}` : command;
|
|
61
72
|
const result = await sshConnection.execWithTimeout(fullCommand, timeout);
|
|
62
73
|
|
|
63
74
|
logger.debug('Command execution result', {
|
|
@@ -67,7 +78,6 @@ export async function handleAcpRemoteExecuteCommand(
|
|
|
67
78
|
stderrLength: result.stderr.length,
|
|
68
79
|
});
|
|
69
80
|
|
|
70
|
-
// Format output as JSON for structured response
|
|
71
81
|
const output: ExecuteCommandResult = {
|
|
72
82
|
stdout: result.stdout,
|
|
73
83
|
stderr: result.stderr,
|
|
@@ -101,3 +111,102 @@ export async function handleAcpRemoteExecuteCommand(
|
|
|
101
111
|
};
|
|
102
112
|
}
|
|
103
113
|
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Execute command with progress streaming
|
|
117
|
+
* Sends real-time progress notifications as output is received
|
|
118
|
+
*
|
|
119
|
+
* @param command - Command to execute
|
|
120
|
+
* @param cwd - Working directory
|
|
121
|
+
* @param sshConnection - SSH connection
|
|
122
|
+
* @param progressToken - Token for progress notifications
|
|
123
|
+
* @param server - Server instance for sending notifications
|
|
124
|
+
*/
|
|
125
|
+
async function executeWithProgress(
|
|
126
|
+
command: string,
|
|
127
|
+
cwd: string | undefined,
|
|
128
|
+
sshConnection: SSHConnectionManager,
|
|
129
|
+
progressToken: string | number,
|
|
130
|
+
server: any
|
|
131
|
+
): Promise<{ content: Array<{ type: string; text: string }> }> {
|
|
132
|
+
logger.debug('Starting streaming execution', { command, cwd, progressToken });
|
|
133
|
+
|
|
134
|
+
const { stream, stderr: stderrStream, exitCode } = await sshConnection.execStream(command, cwd);
|
|
135
|
+
|
|
136
|
+
let stdout = '';
|
|
137
|
+
let stderr = '';
|
|
138
|
+
let bytesReceived = 0;
|
|
139
|
+
let lastProgressTime = 0;
|
|
140
|
+
const MIN_PROGRESS_INTERVAL = 100; // 100ms rate limiting
|
|
141
|
+
|
|
142
|
+
// Stream stdout with progress notifications
|
|
143
|
+
stream.on('data', (chunk: Buffer) => {
|
|
144
|
+
const text = chunk.toString();
|
|
145
|
+
stdout += text;
|
|
146
|
+
bytesReceived += chunk.length;
|
|
147
|
+
|
|
148
|
+
// Rate limiting: only send progress if enough time elapsed
|
|
149
|
+
const now = Date.now();
|
|
150
|
+
if (now - lastProgressTime >= MIN_PROGRESS_INTERVAL) {
|
|
151
|
+
try {
|
|
152
|
+
server.notification({
|
|
153
|
+
method: 'notifications/progress',
|
|
154
|
+
params: {
|
|
155
|
+
progressToken,
|
|
156
|
+
progress: bytesReceived,
|
|
157
|
+
total: undefined, // Unknown total for streaming
|
|
158
|
+
message: text,
|
|
159
|
+
},
|
|
160
|
+
});
|
|
161
|
+
lastProgressTime = now;
|
|
162
|
+
logger.debug('Progress notification sent', {
|
|
163
|
+
progressToken,
|
|
164
|
+
bytes: bytesReceived,
|
|
165
|
+
chunkSize: chunk.length
|
|
166
|
+
});
|
|
167
|
+
} catch (error) {
|
|
168
|
+
logger.warn('Failed to send progress notification', {
|
|
169
|
+
error: error instanceof Error ? error.message : String(error)
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
// Collect stderr (no progress for errors)
|
|
176
|
+
stderrStream.on('data', (chunk: Buffer) => {
|
|
177
|
+
stderr += chunk.toString();
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
// Handle stream errors
|
|
181
|
+
stream.on('error', (error: Error) => {
|
|
182
|
+
logger.error('Stream error during execution', {
|
|
183
|
+
command,
|
|
184
|
+
error: error.message
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
// Wait for completion
|
|
189
|
+
const finalExitCode = await exitCode;
|
|
190
|
+
|
|
191
|
+
logger.debug('Streaming execution completed', {
|
|
192
|
+
command,
|
|
193
|
+
exitCode: finalExitCode,
|
|
194
|
+
stdoutBytes: stdout.length,
|
|
195
|
+
stderrBytes: stderr.length,
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
const output: ExecuteCommandResult = {
|
|
199
|
+
stdout,
|
|
200
|
+
stderr,
|
|
201
|
+
exitCode: finalExitCode,
|
|
202
|
+
timedOut: false,
|
|
203
|
+
streamed: true, // Indicate this was streamed
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
return {
|
|
207
|
+
content: [{
|
|
208
|
+
type: 'text',
|
|
209
|
+
text: JSON.stringify(output, null, 2),
|
|
210
|
+
}],
|
|
211
|
+
};
|
|
212
|
+
}
|
|
@@ -106,10 +106,12 @@ export class SSHConnectionManager {
|
|
|
106
106
|
}
|
|
107
107
|
|
|
108
108
|
const startTime = Date.now();
|
|
109
|
-
|
|
109
|
+
// Wrap command to source shell config for proper PATH and environment
|
|
110
|
+
const wrappedCommand = this.wrapCommandWithShellInit(command);
|
|
111
|
+
logger.sshCommand(wrappedCommand, undefined, timeoutSeconds);
|
|
110
112
|
|
|
111
113
|
const execPromise = new Promise<{ stdout: string; stderr: string; exitCode: number }>((resolve, reject) => {
|
|
112
|
-
this.client.exec(
|
|
114
|
+
this.client.exec(wrappedCommand, (err, stream) => {
|
|
113
115
|
if (err) {
|
|
114
116
|
reject(err);
|
|
115
117
|
return;
|
|
@@ -160,6 +162,74 @@ export class SSHConnectionManager {
|
|
|
160
162
|
}
|
|
161
163
|
}
|
|
162
164
|
|
|
165
|
+
/**
|
|
166
|
+
* Execute a command on the remote server with streaming output
|
|
167
|
+
* Returns streams instead of buffered output for real-time progress
|
|
168
|
+
*
|
|
169
|
+
* @param command - Shell command to execute
|
|
170
|
+
* @param cwd - Optional working directory
|
|
171
|
+
* @returns Object with stdout stream, stderr stream, and exit code promise
|
|
172
|
+
*/
|
|
173
|
+
async execStream(
|
|
174
|
+
command: string,
|
|
175
|
+
cwd?: string
|
|
176
|
+
): Promise<{
|
|
177
|
+
stream: NodeJS.ReadableStream;
|
|
178
|
+
stderr: NodeJS.ReadableStream;
|
|
179
|
+
exitCode: Promise<number>;
|
|
180
|
+
}> {
|
|
181
|
+
if (!this.connected) {
|
|
182
|
+
await this.connect();
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const fullCommand = cwd ? `cd "${cwd}" && ${command}` : command;
|
|
186
|
+
// Wrap command to source shell config for proper PATH and environment
|
|
187
|
+
const wrappedCommand = this.wrapCommandWithShellInit(fullCommand);
|
|
188
|
+
const startTime = Date.now();
|
|
189
|
+
logger.sshCommand(wrappedCommand, cwd);
|
|
190
|
+
|
|
191
|
+
return new Promise((resolve, reject) => {
|
|
192
|
+
this.client.exec(wrappedCommand, (err, stream) => {
|
|
193
|
+
if (err) {
|
|
194
|
+
logger.error('SSH exec failed', {
|
|
195
|
+
command: fullCommand,
|
|
196
|
+
error: err.message
|
|
197
|
+
});
|
|
198
|
+
reject(err);
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
logger.debug('SSH stream started', { command: fullCommand });
|
|
203
|
+
|
|
204
|
+
const exitCodePromise = new Promise<number>((resolveExit) => {
|
|
205
|
+
stream.on('close', (code: number) => {
|
|
206
|
+
const duration = Date.now() - startTime;
|
|
207
|
+
logger.debug('SSH stream closed', {
|
|
208
|
+
command: fullCommand,
|
|
209
|
+
exitCode: code,
|
|
210
|
+
duration: `${duration}ms`
|
|
211
|
+
});
|
|
212
|
+
resolveExit(code);
|
|
213
|
+
});
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
// Handle stream errors
|
|
217
|
+
stream.on('error', (error: Error) => {
|
|
218
|
+
logger.error('SSH stream error', {
|
|
219
|
+
command: fullCommand,
|
|
220
|
+
error: error.message
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
resolve({
|
|
225
|
+
stream: stream,
|
|
226
|
+
stderr: stream.stderr,
|
|
227
|
+
exitCode: exitCodePromise,
|
|
228
|
+
});
|
|
229
|
+
});
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
|
|
163
233
|
/**
|
|
164
234
|
* Get SFTP wrapper for file operations
|
|
165
235
|
*/
|
|
@@ -462,6 +532,20 @@ export class SSHConnectionManager {
|
|
|
462
532
|
});
|
|
463
533
|
}
|
|
464
534
|
|
|
535
|
+
/**
|
|
536
|
+
* Wrap command to source shell configuration files
|
|
537
|
+
* This ensures PATH and other environment variables are properly set
|
|
538
|
+
* SSH non-interactive shells don't source ~/.bashrc or ~/.zshrc by default
|
|
539
|
+
*
|
|
540
|
+
* @param command - The command to wrap
|
|
541
|
+
* @returns Wrapped command that sources shell config first
|
|
542
|
+
*/
|
|
543
|
+
private wrapCommandWithShellInit(command: string): string {
|
|
544
|
+
// Try to source common shell config files
|
|
545
|
+
// Use || true to ignore errors if files don't exist
|
|
546
|
+
return `(source ~/.zshrc 2>/dev/null || source ~/.bashrc 2>/dev/null || source ~/.profile 2>/dev/null || true) && ${command}`;
|
|
547
|
+
}
|
|
548
|
+
|
|
465
549
|
/**
|
|
466
550
|
* Disconnect from the SSH server
|
|
467
551
|
*/
|