ssh-mcp 2.2.6 → 2.3.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.
@@ -0,0 +1,116 @@
1
+ import { writeSync } from 'fs';
2
+ import { inspect } from 'util';
3
+ /**
4
+ * Errors caused by how the server was invoked or configured, as opposed to a
5
+ * defect in it.
6
+ *
7
+ * The distinction exists because the two need to be *presented* differently.
8
+ * `main()` used to hand every failure to `console.error('Fatal error:', error)`,
9
+ * which prints an Error object — name, message and full stack. A missing
10
+ * `--host` therefore reached the operator as a stack trace through
11
+ * `buildAppConfig` and `main`, which reads as a crash. The three lines of
12
+ * explanation we wrote for them were buried inside frames that only matter to
13
+ * someone debugging this codebase. It is the first thing the reporter of #138
14
+ * pasted, and none of it pointed at their actual problem.
15
+ *
16
+ * `reportFatal` below is the other half of that rule and lives here rather than
17
+ * in the CLI entry point, so the reasoning is written once and a unit test can
18
+ * reach it without importing a module that starts a server on import.
19
+ *
20
+ * The name says operator rather than user because in this codebase "user" is
21
+ * the SSH account a command runs as. This is about whoever started the server.
22
+ *
23
+ * Scope: any throw reachable from `main()` before the transport connects should
24
+ * be an OperatorError, and `reportFatal` is the only reader of that promise.
25
+ * `getProfile` also throws one for a profile name that does not exist, which a
26
+ * tool call can trigger at request time — there the message reaches the client
27
+ * through the MCP error rather than through `reportFatal`, and the wording is
28
+ * written for the same audience either way.
29
+ */
30
+ export class OperatorError extends Error {
31
+ constructor(message) {
32
+ super(message);
33
+ this.name = 'OperatorError';
34
+ }
35
+ }
36
+ /**
37
+ * No config file at the path we looked in.
38
+ *
39
+ * Separated from every other config failure because it is the one case that is
40
+ * not necessarily a problem: with no config file, `--host`/`--user` take over.
41
+ * `buildAppConfig` expressed that with a bare `catch {}`, which also swallowed
42
+ * "the file is there and unusable" — a malformed TOML, a schema violation, a
43
+ * permission failure — and reported all of them as "No config file found".
44
+ *
45
+ * That is what made #138 unfindable. A Windows operator with a valid config in
46
+ * the right place was told the file did not exist, while the real error (a
47
+ * permission check that cannot pass on Windows) was discarded unread.
48
+ *
49
+ * The type is the signal rather than the message text, so the fall-through
50
+ * cannot be widened by an unrelated edit to the wording.
51
+ */
52
+ export class ConfigNotFoundError extends OperatorError {
53
+ configPath;
54
+ /**
55
+ * Kept because `loadConfig(customPath)` used to reject with Node's own
56
+ * SystemError for a missing file, and `.code` was observable on it. Nothing
57
+ * in this repo reads it, but a wrapper script plausibly might, and preserving
58
+ * it costs one line.
59
+ */
60
+ code = 'ENOENT';
61
+ constructor(configPath) {
62
+ super(`No config file found at ${configPath}. Create one or use --config <path>. ` +
63
+ 'See documentation for the TOML schema.');
64
+ this.configPath = configPath;
65
+ this.name = 'ConfigNotFoundError';
66
+ }
67
+ }
68
+ /** Exit status for an operator error, so a supervisor can tell it from a crash. */
69
+ export const EXIT_OPERATOR_ERROR = 2;
70
+ /** Exit status for anything else. */
71
+ export const EXIT_DEFECT = 1;
72
+ /**
73
+ * How a failure reaches the operator.
74
+ *
75
+ * An OperatorError is about how the server was invoked or configured, so its
76
+ * message is the whole answer. Anything else keeps the stack, because for a
77
+ * real defect the stack is the part worth having — which also makes the stack a
78
+ * signal: present means "this is ours, report it".
79
+ *
80
+ * Returns the exit status rather than calling `process.exit`, so the two halves
81
+ * of the rule stay testable without spawning a process.
82
+ */
83
+ export function reportFatal(error, log = writeStderr) {
84
+ if (error instanceof OperatorError) {
85
+ log(error.message);
86
+ return EXIT_OPERATOR_ERROR;
87
+ }
88
+ log('Fatal error:', error);
89
+ return EXIT_DEFECT;
90
+ }
91
+ /**
92
+ * The default sink: a synchronous write to fd 2.
93
+ *
94
+ * Not `console.error`, because the caller calls `process.exit` on the next line.
95
+ * Node's writes to stderr are synchronous for files, and for pipes on Linux and
96
+ * Windows — but **asynchronous for pipes on macOS**, and under the stdio
97
+ * transport stderr *is* a pipe owned by the MCP client. So on macOS the message
98
+ * this whole change exists to deliver could be queued and then truncated by the
99
+ * exit, leaving the operator with a disconnect and an exit status. CI runs on
100
+ * Linux, where the pipe is synchronous, so no test would have caught it.
101
+ */
102
+ function writeStderr(...args) {
103
+ let line = '';
104
+ try {
105
+ // Inside the try: `util.inspect` does not catch a throwing
106
+ // `[util.inspect.custom]`, so formatting could throw while reporting a throw, which
107
+ // escaped main()'s catch as an unhandled rejection and printed nothing at all.
108
+ line = args.map((a) => (typeof a === 'string' ? a : inspect(a))).join(' ');
109
+ writeSync(2, `${line}\n`);
110
+ }
111
+ catch {
112
+ // EAGAIN on a non-blocking pipe, or a closed fd. Better a lost line than an
113
+ // exception thrown while reporting an exception.
114
+ console.error(line || `Fatal error: <unprintable ${typeof args[args.length - 1]}>`);
115
+ }
116
+ }
package/build/index.js CHANGED
@@ -1,7 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
3
3
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
4
- import { loadConfig } from './config/loader.js';
4
+ import { loadConfig, getConfigPath } from './config/loader.js';
5
+ import { OperatorError, ConfigNotFoundError, reportFatal } from './errors.js';
5
6
  import { ConnectionRegistry } from './ssh/connection-registry.js';
6
7
  import { PolicyEngine, HOST_GROUPS, resolvePolicyRules } from './policy/engine.js';
7
8
  import { AuditStore } from './audit/store.js';
@@ -59,7 +60,7 @@ function checkRemovedFlags(argv) {
59
60
  const found = Object.keys(REMOVED_V1_FLAGS).filter((f) => f in argv);
60
61
  if (found.length === 0)
61
62
  return;
62
- throw new Error('These flags were removed in v2:\n' +
63
+ throw new OperatorError('These flags were removed in v2:\n' +
63
64
  found.map((f) => ` --${f}: ${REMOVED_V1_FLAGS[f]}`).join('\n') +
64
65
  '\nSee the "Migrating from v1" section of the README.');
65
66
  }
@@ -90,7 +91,7 @@ function resolveHostKeyMode(argv) {
90
91
  return 'tofu';
91
92
  if (mode === 'tofu' || mode === 'strict' || mode === 'insecure')
92
93
  return mode;
93
- throw new Error(`Invalid --hostKeyMode=${mode}. Expected one of: tofu, strict, insecure.`);
94
+ throw new OperatorError(`Invalid --hostKeyMode=${mode}. Expected one of: tofu, strict, insecure.`);
94
95
  }
95
96
  /**
96
97
  * Which policy tier a CLI-configured host belongs to.
@@ -111,21 +112,72 @@ export function resolveHostGroup(argv) {
111
112
  return group;
112
113
  // Falling through would silently apply the prod bindings to a typo, and the
113
114
  // operator would read the refusal as policy rather than as their own slip.
114
- throw new Error(`Invalid --group=${group}. Expected one of: ${HOST_GROUPS.join(', ')}.`);
115
+ throw new OperatorError(`Invalid --group=${group}. Expected one of: ${HOST_GROUPS.join(', ')}.`);
115
116
  }
117
+ /**
118
+ * ACL checks that could not be performed, in the order they happened.
119
+ *
120
+ * Collected rather than only printed so `main()` can act on them once there is somewhere
121
+ * durable to put them; see the note at the sink below.
122
+ */
123
+ const unverified = [];
116
124
  async function buildAppConfig(argv) {
117
- if (argv.config) {
118
- return loadConfig(argv.config);
125
+ // The way past an ACL that could not be read, rather than an operator with no
126
+ // exit. See assertPrivateOnWindows.
127
+ // The sink is supplied here rather than left to the module's default, which is the
128
+ // point of having one: "this security check did not run" is a decision about what the
129
+ // operator must be told, and the loader is not the layer that owns that. Routing it to
130
+ // the audit store as well needs an AuditRecord variant for a startup event — that type
131
+ // is hash-chained and tamper-evident, so it is its own change, and it is queued.
132
+ const aclOpts = {
133
+ allowUnchecked: flagEnabled(argv, 'allowUncheckedConfigAcl'),
134
+ onUnverified: (e) => {
135
+ unverified.push(e);
136
+ console.error(`Warning: the ACL of ${e.path} was not checked (${e.reason}: ${e.detail}), so ` +
137
+ 'whether other accounts can read your config is unverified. Loading it anyway.');
138
+ },
139
+ };
140
+ if (argv.config !== undefined) {
141
+ // `parseArgv` stores null for a flag written without `=`, so a bare `--config` used to
142
+ // be falsy here and fall into the default-path branch — which now refuses rather than
143
+ // silently ignoring an unusable file, so the operator got a refusal naming a path they
144
+ // never asked for.
145
+ if (argv.config === null || argv.config === '') {
146
+ throw new OperatorError('--config needs a path: --config=<path>');
147
+ }
148
+ return loadConfig(argv.config, aclOpts);
119
149
  }
120
150
  try {
121
- return await loadConfig();
151
+ const fromFile = await loadConfig(undefined, aclOpts);
152
+ // Before #138 every Windows config was rejected by the mode-bit check and
153
+ // the rejection was swallowed, so an operator who also passed --host/--user
154
+ // was silently running off the flags. Now that the file loads, it takes
155
+ // precedence — a flip worth one line rather than a surprise about which host
156
+ // a command reached.
157
+ if (argv.host || argv.user) {
158
+ console.error(`Note: ${getConfigPath()} was loaded, so --host/--user are ignored. ` +
159
+ 'Pass --config <path> to choose a different file.');
160
+ }
161
+ return fromFile;
122
162
  }
123
- catch {
124
- // No config file — fall through to CLI args
163
+ catch (err) {
164
+ // Only "there is no file" means fall through. A file that exists and is
165
+ // unusable — malformed TOML, a schema violation, a permission failure —
166
+ // used to be swallowed here too and reported as a missing config, which is
167
+ // how #138 stayed unfindable: the operator's file was in the right place,
168
+ // and the real error was discarded before anyone could read it.
169
+ //
170
+ // Falling through would also be wrong on its own terms whenever
171
+ // --host/--user happen to be present: the server would start, silently
172
+ // ignoring every profile, policy and role binding the operator wrote.
173
+ if (!(err instanceof ConfigNotFoundError))
174
+ throw err;
125
175
  }
126
176
  if (!argv.host || !argv.user) {
127
- throw new Error('No config file found and missing required --host/--user.\n' +
128
- 'Either create a config file at ~/.config/ssh-mcp/config.toml or pass --config <path>.\n' +
177
+ throw new OperatorError('No config file found and missing required --host/--user.\n' +
178
+ // Was hardcoded to ~/.config/ssh-mcp/config.toml on every platform, so on
179
+ // Windows and macOS it named a path this code never reads.
180
+ `Either create a config file at ${getConfigPath()} or pass --config <path>.\n` +
129
181
  'For quick start: --host=<host> --user=<user> (credentials via env vars).');
130
182
  }
131
183
  const defaults = {
@@ -259,8 +311,7 @@ const isTestMode = process.env.SSH_MCP_TEST === '1';
259
311
  const isCliEnabled = process.env.SSH_MCP_DISABLE_MAIN !== '1';
260
312
  if (isCliEnabled || isTestMode) {
261
313
  main().catch((error) => {
262
- console.error('Fatal error:', error);
263
- process.exit(1);
314
+ process.exit(reportFatal(error));
264
315
  });
265
316
  }
266
317
  export { parseArgv, buildAppConfig, checkRemovedFlags, parseMaxChars, flagEnabled, resolveHostKeyMode };
@@ -1,4 +1,5 @@
1
1
  import { classifyCommand, findForbiddenMatch } from './classifier.js';
2
+ import { OperatorError } from '../errors.js';
2
3
  /** Tiers, most restrictive first. Unknown/unset tiers resolve to the first. */
3
4
  export const HOST_GROUPS = ['prod', 'staging', 'dev'];
4
5
  export const DEFAULT_RULES = {
@@ -152,7 +153,7 @@ export function resolvePolicyRules(profiles, override) {
152
153
  const merged = mergePolicyRules(DEFAULT_RULES, override);
153
154
  const problems = findPolicyProblems(merged, profiles, override);
154
155
  if (problems.length > 0) {
155
- throw new Error('Policy configuration error:\n' +
156
+ throw new OperatorError('Policy configuration error:\n' +
156
157
  problems.map((p) => ` ${p}`).join('\n') +
157
158
  '\nSee the "Configuring the matrix" section of the README.');
158
159
  }
@@ -175,7 +176,7 @@ export class PolicyEngine {
175
176
  return new RegExp(pattern);
176
177
  }
177
178
  catch (err) {
178
- throw new Error(`Invalid denylist pattern ${JSON.stringify(pattern)}: ${err instanceof Error ? err.message : String(err)}`);
179
+ throw new OperatorError(`Invalid denylist pattern ${JSON.stringify(pattern)}: ${err instanceof Error ? err.message : String(err)}`);
179
180
  }
180
181
  });
181
182
  }
@@ -1,4 +1,5 @@
1
1
  import { createHash } from 'crypto';
2
+ import { OperatorError } from '../errors.js';
2
3
  export function fingerprintPublicKey(key) {
3
4
  const hash = createHash('sha256').update(key).digest('base64');
4
5
  // `={0,2}` rather than `=+`: base64 padding is never longer than two
@@ -17,7 +18,7 @@ export function verifyHostKey(host, port, fingerprint, knownHosts, mode) {
17
18
  // Two very different things produce this, and the message has to let the
18
19
  // reader tell them apart — otherwise the reflex is --insecureHostKey,
19
20
  // which turns the check off for every host rather than for this one.
20
- throw new Error(`HOST_KEY_MISMATCH: the key presented by ${host}:${port} is not the one seen before.\n` +
21
+ throw new OperatorError(`HOST_KEY_MISMATCH: the key presented by ${host}:${port} is not the one seen before.\n` +
21
22
  ` expected ${stored}\n` +
22
23
  ` received ${fingerprint}\n` +
23
24
  'Either the server was rebuilt, reinstalled or had its address reused — in which case the new key is ' +
@@ -31,7 +32,7 @@ export function verifyHostKey(host, port, fingerprint, knownHosts, mode) {
31
32
  return true;
32
33
  }
33
34
  if (mode === 'strict') {
34
- throw new Error(`HOST_KEY_UNKNOWN: No known key for ${host}:${port} and --hostKeyMode=strict is set. ` +
35
+ throw new OperatorError(`HOST_KEY_UNKNOWN: No known key for ${host}:${port} and --hostKeyMode=strict is set. ` +
35
36
  'Pin the key with trustedHostKey in the profile, or use --hostKeyMode=tofu to trust on first use.');
36
37
  }
37
38
  knownHosts.set(key, fingerprint);
@@ -3,6 +3,7 @@ import { randomUUID, timingSafeEqual } from 'crypto';
3
3
  import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
4
4
  import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js';
5
5
  import { SERVER_VERSION } from '../version.js';
6
+ import { OperatorError } from '../errors.js';
6
7
  const MAX_BODY_SIZE = 1_048_576; // 1MB
7
8
  /** Cap on concurrent MCP sessions, so unauthenticated-adjacent churn can't grow the map without bound. */
8
9
  const MAX_SESSIONS = 64;
@@ -45,7 +46,7 @@ function jsonRpcError(res, status, code, message) {
45
46
  export async function startHttpServer(createMcpServer, opts) {
46
47
  const { port, host = '127.0.0.1', bearerToken, registry } = opts;
47
48
  if (!bearerToken) {
48
- throw new Error('HTTP transport requires --bearerToken. Example: --transport=http --bearerToken=secret\n' +
49
+ throw new OperatorError('HTTP transport requires --bearerToken. Example: --transport=http --bearerToken=secret\n' +
49
50
  'Without authentication, any network client can execute SSH commands on your hosts.');
50
51
  }
51
52
  const rateLimiter = opts.rateLimit && opts.rateLimit > 0
@@ -221,9 +222,14 @@ export async function startHttpServer(createMcpServer, opts) {
221
222
  // immediately regardless, so the caller carried on as if the server was up.
222
223
  await new Promise((resolve, reject) => {
223
224
  httpServer.once('error', (err) => {
224
- reject(err.code === 'EADDRINUSE'
225
- ? new Error(`Cannot bind ${host}:${port} — address already in use.`)
226
- : err);
225
+ // OperatorError, not Error: every listen failure at this point is about how the
226
+ // server was invoked — a port already taken, a privileged port, a mistyped
227
+ // --httpHost. Leaving them on the defect path printed a stack and exited 1, which
228
+ // under this codebase's rule invites the operator to report their own port choice
229
+ // as a bug.
230
+ reject(new OperatorError(err.code === 'EADDRINUSE'
231
+ ? `Cannot bind ${host}:${port} — address already in use.`
232
+ : `Cannot bind ${host}:${port}: ${err.message}`));
227
233
  });
228
234
  httpServer.listen(port, host, () => {
229
235
  console.error(`SSH MCP Server v2 (HTTP) listening on http://${host}:${port}`);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "ssh-mcp",
3
3
  "license": "MIT",
4
- "version": "2.2.6",
4
+ "version": "2.3.0",
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": {
@@ -19,6 +19,7 @@
19
19
  "test:integration": "cross-env SSH_MCP_DISABLE_MAIN=1 vitest --run test/integration/",
20
20
  "test:watch": "cross-env SSH_MCP_DISABLE_MAIN=1 vitest",
21
21
  "coverage": "cross-env SSH_MCP_DISABLE_MAIN=1 vitest --run --coverage",
22
+ "coverage:unit": "cross-env SSH_MCP_DISABLE_MAIN=1 vitest --run --coverage test/unit/ test/property/",
22
23
  "test:e2e": "npm run build && cross-env SSH_MCP_DISABLE_MAIN=1 vitest --run test/e2e/"
23
24
  },
24
25
  "files": [