@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.
@@ -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
- // Build command with working directory if specified
59
- const fullCommand = cwd ? `cd ${cwd} && ${command}` : command;
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
- logger.sshCommand(command, undefined, timeoutSeconds);
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(command, (err, stream) => {
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
  */