ssh-mcp 2.2.1 → 2.2.2

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/README.md CHANGED
@@ -378,6 +378,19 @@ distinguishable from a fresh human answer.
378
378
  **Off by default** (`0` = always prompt). Auto-approval weakens the gate that
379
379
  makes destructive commands safe, so turning it on should be a decision.
380
380
 
381
+ #### Answering the prompt
382
+
383
+ Approval goes through the MCP elicitation request, so what you see is your
384
+ client's dialog. Accepting it approves the command — there is no second field to
385
+ fill in.
386
+
387
+ You have **10 minutes** to answer. Past that the request expires and the command
388
+ is refused rather than left pending, and the refusal says so; the prompt may
389
+ still be open in your client, in which case run the command again once you are
390
+ ready. If your client does not support elicitation at all, every destructive and
391
+ privileged command is refused with `APPROVAL_UNAVAILABLE` naming that cause —
392
+ approval fails closed by design.
393
+
381
394
  ### Command Quota
382
395
 
383
396
  `commandQuotaPerDay` bounds how many commands a profile may run in a rolling
@@ -1,3 +1,17 @@
1
+ import { ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js';
2
+ /**
3
+ * How long a person has to read a destructive command and decide.
4
+ *
5
+ * Explicit because the SDK's DEFAULT_REQUEST_TIMEOUT_MSEC is 60 seconds, and
6
+ * inheriting it here set a human's reading time from a default chosen for
7
+ * machine round trips. An operator who stepped away, or who was working out an
8
+ * unfamiliar dialog, had the request expire underneath them and the command
9
+ * refused (#91).
10
+ *
11
+ * Ten minutes is long enough to be interrupted and come back, and still bounded
12
+ * so a client that never answers cannot pin a tool call open forever.
13
+ */
14
+ export const APPROVAL_TIMEOUT_MS = 600_000;
1
15
  /**
2
16
  * Ask the MCP client to confirm a destructive or privileged command.
3
17
  *
@@ -6,8 +20,9 @@
6
20
  * of silently disabling the gate that guards every destructive command.
7
21
  *
8
22
  * Fails closed: any error (client without elicitation support, transport
9
- * failure, malformed reply) denies the command. The failure is logged, because
10
- * a gate that always denies for an unnoticed reason is its own kind of outage.
23
+ * failure, timeout, malformed reply) denies the command. The failure is logged,
24
+ * because a gate that always denies for an unnoticed reason is its own kind of
25
+ * outage.
11
26
  */
12
27
  export async function requestApproval(server, command, profileName, evaluation) {
13
28
  const message = `Confirm ${evaluation.commandClass} command on "${profileName}":\n\n${command}`;
@@ -22,26 +37,47 @@ export async function requestApproval(server, command, profileName, evaluation)
22
37
  title: 'Approve this command?',
23
38
  },
24
39
  },
25
- required: ['confirm'],
40
+ // `confirm` is deliberately not required. The protocol already carries
41
+ // the decision in `action`, where accept and decline are distinct
42
+ // results, so requiring a boolean on top asked the user to say the
43
+ // same thing twice — and clients render a required boolean as a
44
+ // checkbox beside the accept/decline row. Choosing Accept without
45
+ // ticking it submits a form missing a required field, so the client
46
+ // can produce no valid result and sends `cancel`, which arrived here
47
+ // as "User did not approve this command". The user picked Approve and
48
+ // was told they had declined (#91).
26
49
  },
27
- });
28
- if (result.action === 'accept' && result.content?.confirm === true) {
50
+ }, { timeout: APPROVAL_TIMEOUT_MS });
51
+ // `action` decides. An explicit `confirm: false` from a client that still
52
+ // sends the field is honoured, because refusing to run on an explicit "no"
53
+ // costs nothing and reading it as approval would be indefensible.
54
+ if (result.action === 'accept' && result.content?.confirm !== false) {
29
55
  return { approved: true, approver: 'mcp-client' };
30
56
  }
31
57
  return { approved: false };
32
58
  }
33
59
  catch (err) {
60
+ const timedOut = err instanceof McpError && err.code === ErrorCode.RequestTimeout;
34
61
  const cause = err instanceof Error ? err.message : String(err);
35
62
  // stderr still gets it for the operator's logs, but it cannot be the only
36
63
  // copy: for a stdio MCP server that is a client log file nobody reads, so
37
64
  // the caller gets the diagnosis too and can put it in front of the user.
38
65
  console.error(`Approval request failed for ${evaluation.commandClass} command on "${profileName}" — denying. ` +
39
- `Does this client support elicitation? Cause: ${cause}`);
66
+ `${timedOut ? `No answer within ${APPROVAL_TIMEOUT_MS / 1000}s.` : 'Does this client support elicitation?'} ` +
67
+ `Cause: ${cause}`);
68
+ // Naming the cause rather than guessing at it. This message was introduced
69
+ // to stop APPROVAL_DENIED covering two different failures, and then led with
70
+ // "a client without elicitation support" for every error the catch saw,
71
+ // timeouts included — the same defect, inside its own fix.
40
72
  return {
41
73
  approved: false,
42
- unavailable: `this MCP client could not be asked to approve it. The likely cause is a client without ` +
43
- `elicitation support; a transport failure or a malformed reply does the same. Approval fails ` +
44
- `closed, so the command was refused rather than run. Underlying error: ${cause}`,
74
+ unavailable: timedOut
75
+ ? `no answer arrived within ${APPROVAL_TIMEOUT_MS / 1000}s, so it was refused rather than left ` +
76
+ `pending. The prompt may still be open in your client; approve the command again once you are ` +
77
+ `ready to answer it.`
78
+ : `this MCP client could not be asked to approve it. The likely cause is a client without ` +
79
+ `elicitation support; a transport failure or a malformed reply does the same. Approval fails ` +
80
+ `closed, so the command was refused rather than run. Underlying error: ${cause}`,
45
81
  };
46
82
  }
47
83
  }
package/build/index.js CHANGED
@@ -24,6 +24,27 @@ function parseArgv() {
24
24
  }
25
25
  return config;
26
26
  }
27
+ /**
28
+ * Whether a boolean flag was passed at all.
29
+ *
30
+ * `parseArgv` stores `null` for a flag written without `=`, which is how every
31
+ * documented boolean flag is written. Testing that value for truthiness makes
32
+ * the flag a no-op: `--disableApproval`, `--auditEntropyScan` and
33
+ * `--auditTamperEvident` all did nothing unless spelled `--flag=1`, which
34
+ * nothing documents (#91). The two call sites that got it right already used
35
+ * `!== undefined`; this gives the check a name so the next one cannot miss it.
36
+ *
37
+ * `--flag=false` and `--flag=0` turn it off, because a flag that cannot be
38
+ * turned off once written into a wrapper script is its own annoyance.
39
+ */
40
+ function flagEnabled(argv, name) {
41
+ if (!(name in argv))
42
+ return false;
43
+ const value = argv[name];
44
+ if (value === null || value === '')
45
+ return true;
46
+ return !['false', '0', 'no', 'off'].includes(value.toLowerCase());
47
+ }
27
48
  /**
28
49
  * v1 flags that v2 removed. Silently ignoring them means the user only finds
29
50
  * out at the first command, as an auth failure with no hint about the cause.
@@ -62,7 +83,7 @@ function parseMaxChars(raw) {
62
83
  * existed. `--insecureHostKey` is kept as an alias for the documented flag.
63
84
  */
64
85
  function resolveHostKeyMode(argv) {
65
- if (argv.insecureHostKey !== undefined)
86
+ if (flagEnabled(argv, 'insecureHostKey'))
66
87
  return 'insecure';
67
88
  const mode = argv.hostKeyMode;
68
89
  if (mode === null || mode === undefined)
@@ -134,7 +155,7 @@ async function buildAppConfig(argv) {
134
155
  role: 'admin',
135
156
  group: resolveHostGroup(argv),
136
157
  readOnly: false,
137
- approvalPolicy: argv.disableApproval ? 'auto' : defaults.approvalMode,
158
+ approvalPolicy: flagEnabled(argv, 'disableApproval') ? 'auto' : defaults.approvalMode,
138
159
  cert: false,
139
160
  sessionMaxPerConnection: defaults.sessionMaxPerConnection,
140
161
  sessionIdleTimeoutMs: defaults.sessionIdleTimeoutMs,
@@ -152,8 +173,8 @@ async function main() {
152
173
  }
153
174
  const config = await buildAppConfig(argv);
154
175
  const hostKeyMode = resolveHostKeyMode(argv);
155
- const entropyScan = !!argv.auditEntropyScan;
156
- const tamperEvident = !!argv.auditTamperEvident;
176
+ const entropyScan = flagEnabled(argv, 'auditEntropyScan');
177
+ const tamperEvident = flagEnabled(argv, 'auditTamperEvident');
157
178
  await initKeychain();
158
179
  if (argv.otelEndpoint) {
159
180
  const { initTracing } = await import('./observability/tracer.js');
@@ -242,4 +263,4 @@ if (isCliEnabled || isTestMode) {
242
263
  process.exit(1);
243
264
  });
244
265
  }
245
- export { parseArgv, buildAppConfig, checkRemovedFlags, parseMaxChars };
266
+ export { parseArgv, buildAppConfig, checkRemovedFlags, parseMaxChars, flagEnabled, resolveHostKeyMode };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "ssh-mcp",
3
3
  "license": "MIT",
4
- "version": "2.2.1",
4
+ "version": "2.2.2",
5
5
  "description": "MCP server exposing policy-gated, audited SSH access for Linux and Windows hosts via the Model Context Protocol.",
6
6
  "type": "module",
7
7
  "bin": {