ssh-mcp 2.3.3 → 2.4.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.
package/README.md CHANGED
@@ -23,6 +23,10 @@ npm install -g ssh-mcp
23
23
 
24
24
  ### 2. Configure
25
25
 
26
+ Without a config the server still starts, so a client or directory can complete the MCP
27
+ handshake and read `tools/list` — but **every tool call is refused** until you configure it,
28
+ with a message naming the path below. Nothing runs on a host until this step is done.
29
+
26
30
  Create the config file at the path for your platform:
27
31
 
28
32
  | Platform | Path |
@@ -98,6 +102,12 @@ nothing. Neither combination leaves you without an exit, which is the lesson of
98
102
  A supervisor that treats any non-zero status as a failure needs no change. One
99
103
  that matched on `1` to detect a startup problem should match on `2` as well.
100
104
 
105
+ Starting with nothing configured is **not** an exit-2 condition, as of the release that
106
+ added introspection without a config: the server starts so it can be described, and
107
+ refuses each tool call instead. A supervisor that used a non-zero exit to catch an
108
+ unconfigured deployment should watch for `starting unconfigured` on stderr, or read
109
+ `configured` from `GET /health` when running the HTTP transport.
110
+
101
111
  ### 3. Set credentials via environment variables
102
112
 
103
113
  ```bash
@@ -565,6 +575,12 @@ ssh-mcp --transport=http --httpPort=3000 --bearerToken=secret --rateLimit=60
565
575
 
566
576
  Endpoints: `POST /` (MCP Streamable HTTP), `GET /status`, `GET /health`
567
577
 
578
+ `GET /health` answers `{"healthy": true, "configured": <bool>}`. It stays `200` either way —
579
+ `healthy` is liveness — while `configured` is false when no profile is set, which is the
580
+ case of a config bind mount that silently did not attach: the server binds the port and
581
+ refuses every tool call. `GET /status` carries the profile list itself and stays behind the
582
+ bearer token.
583
+
568
584
  When rate limit is exceeded, the server returns HTTP 429 with `Retry-After` header and a JSON-RPC error body so MCP clients can handle it gracefully.
569
585
 
570
586
  **Always terminate TLS at a reverse proxy** (Caddy/nginx). The server listens on `127.0.0.1` only.
package/build/cli.js ADDED
@@ -0,0 +1,259 @@
1
+ /**
2
+ * The command line: argv into an AppConfig.
3
+ *
4
+ * Split out of index.ts, which runs `main()` at import time unless
5
+ * `SSH_MCP_DISABLE_MAIN=1` is set *and* `SSH_MCP_TEST` is not `1` — the gate is a
6
+ * disjunction, so the test variable overrides the disable one. That made every test of a
7
+ * pure argv helper a test that could start connecting to real hosts on a machine with a
8
+ * real config; one of them carried a six-line comment and a `vi.stubEnv` about exactly
9
+ * that, both removed by this split. Nothing in this file has a side effect at import
10
+ * time, so importing it is safe.
11
+ *
12
+ * Not *everything* argv-shaped lives here: index.ts still reads the transport, OTEL and
13
+ * OPA flags at the wiring site, where they are used. What moved is the part that had to
14
+ * escape the boot gate to be testable.
15
+ */
16
+ import { loadConfig, getConfigPath } from './config/loader.js';
17
+ import { OperatorError, ConfigNotFoundError, UnconfiguredError } from './errors.js';
18
+ import { HOST_GROUPS } from './policy/engine.js';
19
+ /**
20
+ * Whether the operator asked for a quick start at all.
21
+ *
22
+ * Presence, not truthiness — the same #91 trap `flagEnabled` was written for. `parseArgv`
23
+ * stores `null` for a flag written without `=` and drops bare words, so all three of
24
+ * `--host example.com --user root` (space instead of `=`, the commonest slip), `--host`
25
+ * alone, and `--host= --user=` (a wrapper interpolating unset env vars) produce falsy
26
+ * values while the flags were plainly given. Testing truthiness sent every one of them
27
+ * down the soft path: a server that starts and looks healthy, with the explanation only
28
+ * on a stderr stream an MCP client typically swallows. Only "asked for nothing" softens;
29
+ * a half-given or empty-valued quick start is a typo and still refuses.
30
+ */
31
+ function nothingRequested(argv) {
32
+ return !('host' in argv) && !('user' in argv);
33
+ }
34
+ export function parseArgv(args = process.argv.slice(2)) {
35
+ const config = {};
36
+ for (const arg of args) {
37
+ if (arg.startsWith('--')) {
38
+ const eq = arg.indexOf('=');
39
+ if (eq === -1) {
40
+ config[arg.slice(2)] = null;
41
+ }
42
+ else {
43
+ config[arg.slice(2, eq)] = arg.slice(eq + 1);
44
+ }
45
+ }
46
+ }
47
+ return config;
48
+ }
49
+ /**
50
+ * Whether a boolean flag was passed at all.
51
+ *
52
+ * `parseArgv` stores `null` for a flag written without `=`, which is how every
53
+ * documented boolean flag is written. Testing that value for truthiness makes
54
+ * the flag a no-op: `--disableApproval`, `--auditEntropyScan` and
55
+ * `--auditTamperEvident` all did nothing unless spelled `--flag=1`, which
56
+ * nothing documents (#91). The two call sites that got it right already used
57
+ * `!== undefined`; this gives the check a name so the next one cannot miss it.
58
+ *
59
+ * `--flag=false` and `--flag=0` turn it off, because a flag that cannot be
60
+ * turned off once written into a wrapper script is its own annoyance.
61
+ */
62
+ export function flagEnabled(argv, name) {
63
+ if (!(name in argv))
64
+ return false;
65
+ const value = argv[name];
66
+ if (value === null || value === '')
67
+ return true;
68
+ return !['false', '0', 'no', 'off'].includes(value.toLowerCase());
69
+ }
70
+ /**
71
+ * v1 flags that v2 removed. Silently ignoring them means the user only finds
72
+ * out at the first command, as an auth failure with no hint about the cause.
73
+ */
74
+ const REMOVED_V1_FLAGS = {
75
+ password: 'Use the SSH_MCP_PASSWORD env var (or a per-profile SSH_MCP_<PROFILE>_PASSWORD).',
76
+ suPassword: 'Use the SSH_MCP_SUDO_PASSWORD env var.',
77
+ sudoPassword: 'Use the SSH_MCP_SUDO_PASSWORD env var.',
78
+ disableSudo: 'Sudo is now a separate tool; restrict it with a role/policy that disallows the "privileged" class.',
79
+ };
80
+ export function checkRemovedFlags(argv) {
81
+ const found = Object.keys(REMOVED_V1_FLAGS).filter((f) => f in argv);
82
+ if (found.length === 0)
83
+ return;
84
+ throw new OperatorError('These flags were removed in v2:\n' +
85
+ found.map((f) => ` --${f}: ${REMOVED_V1_FLAGS[f]}`).join('\n') +
86
+ '\nSee the "Migrating from v1" section of the README.');
87
+ }
88
+ /**
89
+ * v1 documented `--maxChars=none` (and 0/negative) as "no limit". Parsing it
90
+ * with `parseInt(...) || 5000` silently turned that into a 5000-char cap.
91
+ */
92
+ export function parseMaxChars(raw) {
93
+ if (typeof raw !== 'string' || raw === '')
94
+ return 5_000;
95
+ if (raw.toLowerCase() === 'none')
96
+ return Number.MAX_SAFE_INTEGER;
97
+ const parsed = parseInt(raw);
98
+ if (isNaN(parsed))
99
+ return 5_000;
100
+ return parsed <= 0 ? Number.MAX_SAFE_INTEGER : parsed;
101
+ }
102
+ /**
103
+ * `strict` was previously unreachable: nothing but test code could select it,
104
+ * yet host-key.ts pointed users at a `--acceptNewHostKey` flag that never
105
+ * existed. `--insecureHostKey` is kept as an alias for the documented flag.
106
+ */
107
+ export function resolveHostKeyMode(argv) {
108
+ if (flagEnabled(argv, 'insecureHostKey'))
109
+ return 'insecure';
110
+ const mode = argv.hostKeyMode;
111
+ if (mode === null || mode === undefined)
112
+ return 'tofu';
113
+ if (mode === 'tofu' || mode === 'strict' || mode === 'insecure')
114
+ return mode;
115
+ throw new OperatorError(`Invalid --hostKeyMode=${mode}. Expected one of: tofu, strict, insecure.`);
116
+ }
117
+ /**
118
+ * Which policy tier a CLI-configured host belongs to.
119
+ *
120
+ * Without this there was no way to say it from the command line at all. The
121
+ * inline profile carries no group, so it fell to the strictest tier, where the
122
+ * `admin` role has no `privileged` — meaning `sudo` could never run for anyone
123
+ * who had not written a config file (#91).
124
+ *
125
+ * The default stays `prod`. Guessing an unknown host is production is the safe
126
+ * direction; what was missing was a way to correct the guess.
127
+ */
128
+ export function resolveHostGroup(argv) {
129
+ const group = argv.group;
130
+ if (group === null || group === undefined)
131
+ return 'prod';
132
+ if (HOST_GROUPS.includes(group))
133
+ return group;
134
+ // Falling through would silently apply the prod bindings to a typo, and the
135
+ // operator would read the refusal as policy rather than as their own slip.
136
+ throw new OperatorError(`Invalid --group=${group}. Expected one of: ${HOST_GROUPS.join(', ')}.`);
137
+ }
138
+ /**
139
+ * The defaults a quick-start profile inherits, from flags alone.
140
+ *
141
+ * Named because an unconfigured start needs them too — `approvalGrantTtlMs` is read while
142
+ * wiring the tools, long before any profile exists, so the two paths have to agree.
143
+ */
144
+ export function defaultsFromArgv(argv) {
145
+ return {
146
+ sessionMaxPerConnection: parseInt(argv.sessionMax) || 5,
147
+ sessionIdleTimeoutMs: parseInt(argv.sessionTtl) || 600_000,
148
+ sessionBackgroundMaxMs: 3_600_000,
149
+ commandTimeoutMs: parseInt(argv.timeout) || 60_000,
150
+ commandMaxChars: parseMaxChars(argv.maxChars),
151
+ commandMaxOutputBytes: 1_048_576,
152
+ connectionIdleReapMs: 900_000,
153
+ commandQuotaPerDay: parseInt(argv.commandQuota) || 0,
154
+ approvalGrantTtlMs: parseInt(argv.approvalGrantTtl) || 0,
155
+ approvalMode: 'ask-destructive',
156
+ };
157
+ }
158
+ export async function buildAppConfig(argv) {
159
+ // The way past an ACL that could not be read, rather than an operator with no
160
+ // exit. See assertPrivateOnWindows.
161
+ // The sink is supplied here rather than left to the module's default, which is the
162
+ // point of having one: "this security check did not run" is a decision about what the
163
+ // operator must be told, and the loader is not the layer that owns that. Routing it to
164
+ // the audit store as well needs an AuditRecord variant for a startup event — that type
165
+ // is hash-chained and tamper-evident, so it is its own change, and it is queued.
166
+ //
167
+ // An earlier version also pushed every finding into a module-level array "so main() can
168
+ // act on them". Nothing ever read it, and this split made the claim impossible — main()
169
+ // is in another module and the array was not exported. Re-adding a collection point is
170
+ // one line when there is a consumer to write it against.
171
+ const aclOpts = {
172
+ enforce: flagEnabled(argv, 'strictConfigAcl'),
173
+ allowUnchecked: flagEnabled(argv, 'allowUncheckedConfigAcl'),
174
+ onFinding: (f) => {
175
+ console.error(`Warning: ${f.message}`);
176
+ },
177
+ };
178
+ if (argv.config !== undefined) {
179
+ // `parseArgv` stores null for a flag written without `=`, so a bare `--config` used to
180
+ // be falsy here and fall into the default-path branch — which now refuses rather than
181
+ // silently ignoring an unusable file, so the operator got a refusal naming a path they
182
+ // never asked for.
183
+ if (argv.config === null || argv.config === '') {
184
+ throw new OperatorError('--config needs a path: --config=<path>');
185
+ }
186
+ return loadConfig(argv.config, aclOpts);
187
+ }
188
+ try {
189
+ const fromFile = await loadConfig(undefined, aclOpts);
190
+ // Before #138 every Windows config was rejected by the mode-bit check and
191
+ // the rejection was swallowed, so an operator who also passed --host/--user
192
+ // was silently running off the flags. Now that the file loads, it takes
193
+ // precedence — a flip worth one line rather than a surprise about which host
194
+ // a command reached.
195
+ if (argv.host || argv.user) {
196
+ console.error(`Note: ${getConfigPath()} was loaded, so --host/--user are ignored. ` +
197
+ 'Pass --config <path> to choose a different file.');
198
+ }
199
+ return fromFile;
200
+ }
201
+ catch (err) {
202
+ // Only "there is no file" means fall through. A file that exists and is
203
+ // unusable — malformed TOML, a schema violation, a permission failure —
204
+ // used to be swallowed here too and reported as a missing config, which is
205
+ // how #138 stayed unfindable: the operator's file was in the right place,
206
+ // and the real error was discarded before anyone could read it.
207
+ //
208
+ // Falling through would also be wrong on its own terms whenever
209
+ // --host/--user happen to be present: the server would start, silently
210
+ // ignoring every profile, policy and role binding the operator wrote.
211
+ if (!(err instanceof ConfigNotFoundError))
212
+ throw err;
213
+ }
214
+ // Nothing configured, and nothing named on the command line either. Start anyway, with
215
+ // no profiles: an MCP client or directory can then complete the handshake and read
216
+ // `tools/list`, and every tool call is refused by `ConnectionRegistry` with the message
217
+ // below — including `list-connections`, which resolves no profile and so had to be told.
218
+ //
219
+ // Measured before this: `initialize` and `tools/list` against our own image drew no
220
+ // JSON-RPC response at all — the process exited on this check first — so every directory
221
+ // and every "add this server" flow saw a crash. Tool definitions are static metadata and
222
+ // the config decides what they may *reach*, so coupling the two bought no safety.
223
+ //
224
+ // A half-given quick start is not this case: `--host` without `--user` is a typo, and an
225
+ // explicit `--config` that points nowhere already threw above. Only "configured nothing"
226
+ // softens — see `nothingRequested`, which tests for the flags being absent rather than
227
+ // falsy so that a valueless or space-separated one stays a typo.
228
+ if (nothingRequested(argv)) {
229
+ console.error(`Warning: starting unconfigured — every tool call will be refused.\n${new UnconfiguredError(getConfigPath()).message}`);
230
+ return { defaults: defaultsFromArgv(argv), profiles: [] };
231
+ }
232
+ if (!argv.host || !argv.user) {
233
+ throw new UnconfiguredError(getConfigPath());
234
+ }
235
+ const defaults = defaultsFromArgv(argv);
236
+ const profile = {
237
+ name: 'default',
238
+ host: argv.host,
239
+ port: parseInt(argv.port) || 22,
240
+ user: argv.user,
241
+ auth: argv.key ? 'key' : 'password',
242
+ keyRef: argv.key || undefined,
243
+ workdir: argv.workdir || undefined,
244
+ tty: false,
245
+ timeout: defaults.commandTimeoutMs,
246
+ maxChars: defaults.commandMaxChars,
247
+ maxOutputBytes: defaults.commandMaxOutputBytes,
248
+ role: 'admin',
249
+ group: resolveHostGroup(argv),
250
+ readOnly: false,
251
+ approvalPolicy: flagEnabled(argv, 'disableApproval') ? 'auto' : defaults.approvalMode,
252
+ cert: false,
253
+ sessionMaxPerConnection: defaults.sessionMaxPerConnection,
254
+ sessionIdleTimeoutMs: defaults.sessionIdleTimeoutMs,
255
+ sessionBackgroundMaxMs: defaults.sessionBackgroundMaxMs,
256
+ commandQuotaPerDay: defaults.commandQuotaPerDay,
257
+ };
258
+ return { defaults, profiles: [profile] };
259
+ }
@@ -3,7 +3,7 @@ import { homedir, platform } from 'os';
3
3
  import { join, dirname } from 'path';
4
4
  import { parse as parseTOML } from 'smol-toml';
5
5
  import { configSchema } from './schema.js';
6
- import { OperatorError, ConfigNotFoundError } from '../errors.js';
6
+ import { OperatorError, ConfigNotFoundError, UnconfiguredError } from '../errors.js';
7
7
  import { assertPrivateOnWindows } from './windows-acl.js';
8
8
  export function getConfigPath(customPath) {
9
9
  if (customPath)
@@ -188,6 +188,14 @@ export function getProfile(config, name) {
188
188
  throw new OperatorError(`No profile selected and no default configured, but ${config.profiles.length} profiles exist: ${names}. ` +
189
189
  `Pass a "profile" argument, or set defaults.defaultProfile in the config.`);
190
190
  }
191
+ // Defensive: a lookup helper must not hand back `undefined` typed as `Profile`, which
192
+ // is what `profiles[0]` on an empty list used to do — a TypeError somewhere downstream
193
+ // rather than an explanation here. This is the invariant, not the product rule. The
194
+ // decision that an unconfigured server refuses work lives in `ConnectionRegistry`,
195
+ // which guards this branch AND the named-profile one below; reaching this line means
196
+ // a caller went around the registry.
197
+ if (config.profiles.length === 0)
198
+ throw new UnconfiguredError(getConfigPath());
191
199
  return config.profiles[0];
192
200
  }
193
201
  const profile = config.profiles.find((p) => p.name === targetName);