Skip to content

Commit 1f80b59

Browse files
author
Jordan Munch O'Hare
committed
fix: limits on retries
1 parent 16a4d30 commit 1f80b59

File tree

5 files changed

+90
-17
lines changed

5 files changed

+90
-17
lines changed

.env.example

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,3 +26,11 @@ SSH_PRIVATE_KEY_PATH=/home/mcp/.ssh/id_rsa
2626
# For Unraid: /root/.ssh/id_rsa_mcp or /boot/config/ssh/id_rsa_mcp
2727
# For other systems: /home/user/.ssh/id_rsa_mcp
2828
SSH_KEY_HOST_PATH=/root/.ssh/id_rsa_mcp
29+
30+
# Command execution timeout in milliseconds (default: 15000 = 15 seconds)
31+
# Increase for long-running commands like database dumps
32+
COMMAND_TIMEOUT_MS=15000
33+
34+
# Maximum consecutive command failures before circuit breaker opens (default: 3)
35+
# When circuit breaker is open, commands will fail immediately to prevent retry loops
36+
MAX_CONSECUTIVE_FAILURES=3

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "mcp-ssh-unraid",
3-
"version": "1.0.0",
3+
"version": "1.1.0",
44
"description": "MCP server for SSH access to Unraid",
55
"main": "dist/index.js",
66
"type": "module",

src/__tests__/http-server.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ describe('HTTP Server', () => {
6565
const data = await response.json() as Record<string, any>;
6666
expect(data).toHaveProperty('status');
6767
expect(data).toHaveProperty('server', 'mcp-ssh-unraid');
68-
expect(data).toHaveProperty('version', '1.0.0');
68+
expect(data).toHaveProperty('version', '1.1.0');
6969
expect(data).toHaveProperty('transport', 'http');
7070
expect(data).toHaveProperty('ssh_connected');
7171

src/http-server.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,7 @@ async function main() {
121121
log.info("Initializing MCP server...");
122122
const server = new McpServer({
123123
name: "ssh-unraid-server-http",
124-
version: "1.0.0",
124+
version: "1.1.0",
125125
});
126126

127127
// Create SSH executor adapter for tool modules
@@ -311,7 +311,7 @@ async function main() {
311311
status,
312312
ssh_connected: isSSHConnected,
313313
server: "mcp-ssh-unraid",
314-
version: "1.0.0",
314+
version: "1.1.0",
315315
transport: "http",
316316
oauth: "enabled",
317317
});

src/ssh-manager.ts

Lines changed: 78 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,10 @@ export class SSHConnectionManager {
1818
private reconnectAttempts: number = 0;
1919
private maxReconnectAttempts: number = 5;
2020
private baseBackoffMs: number = 1000;
21+
private commandTimeoutMs: number;
22+
private maxConsecutiveFailures: number;
23+
private consecutiveFailures: number = 0;
24+
private circuitBreakerOpen: boolean = false;
2125

2226
constructor() {
2327
this.ssh = new NodeSSH();
@@ -46,6 +50,14 @@ export class SSHConnectionManager {
4650
privateKeyPath,
4751
password,
4852
};
53+
54+
// Load timeout and circuit breaker configuration
55+
this.commandTimeoutMs = process.env.COMMAND_TIMEOUT_MS
56+
? parseInt(process.env.COMMAND_TIMEOUT_MS)
57+
: 15000; // Default: 15 seconds
58+
this.maxConsecutiveFailures = process.env.MAX_CONSECUTIVE_FAILURES
59+
? parseInt(process.env.MAX_CONSECUTIVE_FAILURES)
60+
: 3; // Default: 3 consecutive failures
4961
}
5062

5163
/**
@@ -93,36 +105,89 @@ export class SSHConnectionManager {
93105
}
94106

95107
/**
96-
* Execute command via SSH
108+
* Execute command via SSH with timeout and circuit breaker protection
97109
*/
98110
async executeCommand(command: string): Promise<{ stdout: string; stderr: string; exitCode: number }> {
111+
// Check circuit breaker
112+
if (this.circuitBreakerOpen) {
113+
throw new Error(
114+
`Circuit breaker is open after ${this.consecutiveFailures} consecutive failures. ` +
115+
`Please check server health or restart the MCP server to reset.`
116+
);
117+
}
118+
119+
// Timeout ID for cleanup
120+
let timeoutId: NodeJS.Timeout | null = null;
121+
99122
try {
100123
if (!this.connected) {
101124
await this.connect();
102125
}
103126

104-
const result = await this.ssh.execCommand(command);
127+
// Create a timeout promise
128+
const timeoutPromise = new Promise<never>((_, reject) => {
129+
timeoutId = setTimeout(() => {
130+
reject(new Error(`TIMEOUT: Command timed out after ${this.commandTimeoutMs}ms`));
131+
}, this.commandTimeoutMs);
132+
});
133+
134+
// Race between command execution and timeout
135+
const result = await Promise.race([
136+
this.ssh.execCommand(command),
137+
timeoutPromise,
138+
]);
139+
140+
// Clear timeout on success
141+
if (timeoutId) clearTimeout(timeoutId);
142+
143+
// Reset circuit breaker on successful command
144+
this.consecutiveFailures = 0;
145+
this.circuitBreakerOpen = false;
105146

106147
return {
107148
stdout: result.stdout,
108149
stderr: result.stderr,
109150
exitCode: result.code ?? 0,
110151
};
111152
} catch (error) {
112-
// Attempt to reconnect on connection errors
113-
if (error instanceof Error && error.message.includes("connection")) {
153+
// Clear timeout on error
154+
if (timeoutId) clearTimeout(timeoutId);
155+
156+
// Increment failure counter
157+
this.consecutiveFailures++;
158+
159+
// Open circuit breaker if threshold reached
160+
if (this.consecutiveFailures >= this.maxConsecutiveFailures) {
161+
this.circuitBreakerOpen = true;
162+
console.error(
163+
`Circuit breaker opened after ${this.consecutiveFailures} consecutive failures. ` +
164+
`Future commands will fail immediately until the MCP server is restarted.`
165+
);
166+
}
167+
168+
// Determine error type for better error messages
169+
const errorMessage = error instanceof Error ? error.message : String(error);
170+
const isTimeout = errorMessage.includes("TIMEOUT:");
171+
const isConnection = errorMessage.toLowerCase().includes("connection");
172+
173+
if (isTimeout) {
174+
throw new Error(
175+
`Command timed out after ${this.commandTimeoutMs}ms. ` +
176+
`The command may be hung or taking too long. ` +
177+
`Consider increasing COMMAND_TIMEOUT_MS if this is a long-running operation.`
178+
);
179+
}
180+
181+
if (isConnection) {
114182
this.connected = false;
115-
await this.reconnect();
116-
// Retry the command after reconnection
117-
const result = await this.ssh.execCommand(command);
118-
return {
119-
stdout: result.stdout,
120-
stderr: result.stderr,
121-
exitCode: result.code ?? 0,
122-
};
183+
throw new Error(
184+
`SSH connection lost: ${errorMessage}. ` +
185+
`The MCP server will attempt to reconnect on the next command. ` +
186+
`If this persists, check your network connection and SSH credentials.`
187+
);
123188
}
124189

125-
throw new Error(`Failed to execute command: ${error instanceof Error ? error.message : String(error)}`);
190+
throw new Error(`Failed to execute command: ${errorMessage}`);
126191
}
127192
}
128193

0 commit comments

Comments
 (0)