ssh-mcp 2.3.4 → 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 CHANGED
@@ -14,8 +14,23 @@
14
14
  * escape the boot gate to be testable.
15
15
  */
16
16
  import { loadConfig, getConfigPath } from './config/loader.js';
17
- import { OperatorError, ConfigNotFoundError } from './errors.js';
17
+ import { OperatorError, ConfigNotFoundError, UnconfiguredError } from './errors.js';
18
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
+ }
19
34
  export function parseArgv(args = process.argv.slice(2)) {
20
35
  const config = {};
21
36
  for (const arg of args) {
@@ -120,6 +135,26 @@ export function resolveHostGroup(argv) {
120
135
  // operator would read the refusal as policy rather than as their own slip.
121
136
  throw new OperatorError(`Invalid --group=${group}. Expected one of: ${HOST_GROUPS.join(', ')}.`);
122
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
+ }
123
158
  export async function buildAppConfig(argv) {
124
159
  // The way past an ACL that could not be read, rather than an operator with no
125
160
  // exit. See assertPrivateOnWindows.
@@ -176,25 +211,28 @@ export async function buildAppConfig(argv) {
176
211
  if (!(err instanceof ConfigNotFoundError))
177
212
  throw err;
178
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
+ }
179
232
  if (!argv.host || !argv.user) {
180
- throw new OperatorError('No config file found and missing required --host/--user.\n' +
181
- // Was hardcoded to ~/.config/ssh-mcp/config.toml on every platform, so on
182
- // Windows and macOS it named a path this code never reads.
183
- `Either create a config file at ${getConfigPath()} or pass --config <path>.\n` +
184
- 'For quick start: --host=<host> --user=<user> (credentials via env vars).');
233
+ throw new UnconfiguredError(getConfigPath());
185
234
  }
186
- const defaults = {
187
- sessionMaxPerConnection: parseInt(argv.sessionMax) || 5,
188
- sessionIdleTimeoutMs: parseInt(argv.sessionTtl) || 600_000,
189
- sessionBackgroundMaxMs: 3_600_000,
190
- commandTimeoutMs: parseInt(argv.timeout) || 60_000,
191
- commandMaxChars: parseMaxChars(argv.maxChars),
192
- commandMaxOutputBytes: 1_048_576,
193
- connectionIdleReapMs: 900_000,
194
- commandQuotaPerDay: parseInt(argv.commandQuota) || 0,
195
- approvalGrantTtlMs: parseInt(argv.approvalGrantTtl) || 0,
196
- approvalMode: 'ask-destructive',
197
- };
235
+ const defaults = defaultsFromArgv(argv);
198
236
  const profile = {
199
237
  name: 'default',
200
238
  host: argv.host,
@@ -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);
package/build/errors.js CHANGED
@@ -65,6 +65,29 @@ export class ConfigNotFoundError extends OperatorError {
65
65
  this.name = 'ConfigNotFoundError';
66
66
  }
67
67
  }
68
+ /**
69
+ * Nothing is configured: no config file, and no `--host`/`--user` on the command line.
70
+ *
71
+ * A type rather than a bare `OperatorError` for the reason `ConfigNotFoundError` is one —
72
+ * two layers have to agree on this state without agreeing on a string. `cli.ts` prints it
73
+ * as a startup warning and the server keeps running; `ConnectionRegistry` throws it at
74
+ * every tool call. Those are different audiences reached through different channels, and
75
+ * a wording edit in one must not quietly stop matching the other.
76
+ *
77
+ * The path is a constructor argument rather than a `getConfigPath()` call inside the
78
+ * message, so this file stays free of the config layer and the text is testable without
79
+ * stubbing `os.platform()`.
80
+ */
81
+ export class UnconfiguredError extends OperatorError {
82
+ configPath;
83
+ constructor(configPath) {
84
+ super('No config file found and missing required --host/--user.\n' +
85
+ `Either create a config file at ${configPath} or pass --config <path>.\n` +
86
+ 'For quick start: --host=<host> --user=<user> (credentials via env vars).');
87
+ this.configPath = configPath;
88
+ this.name = 'UnconfiguredError';
89
+ }
90
+ }
68
91
  /** Exit status for an operator error, so a supervisor can tell it from a crash. */
69
92
  export const EXIT_OPERATOR_ERROR = 2;
70
93
  /** Exit status for anything else. */
@@ -1,4 +1,5 @@
1
- import { getProfile as getConfigProfile } from '../config/loader.js';
1
+ import { getProfile as getConfigProfile, getConfigPath } from '../config/loader.js';
2
+ import { UnconfiguredError } from '../errors.js';
2
3
  import { resolveCredentials } from '../config/credential-resolver.js';
3
4
  import { SSHConnection } from './connection.js';
4
5
  export class ConnectionRegistry {
@@ -69,7 +70,25 @@ export class ConnectionRegistry {
69
70
  const profile = this.getProfile(profileName);
70
71
  return this.connections.get(profile.name);
71
72
  }
73
+ /**
74
+ * Refuse everything while nothing is configured.
75
+ *
76
+ * The server now starts with `profiles: []` so a directory or a client's "add server"
77
+ * flow can read `tools/list` (see cli.ts). This is where that state stops being
78
+ * readable and starts being refused, and it sits here rather than in the loader's
79
+ * `getProfile` for one measured reason: the loader's empty check can only live in the
80
+ * no-profile-named branch, so a client calling `run-command` with `profile: "prod"`
81
+ * against an unconfigured server got `Profile "prod" not found` — a message telling the
82
+ * operator they mistyped a name when in fact they have no config at all, and omitting
83
+ * the config path this whole change exists to deliver. Checking before the lookup
84
+ * covers both.
85
+ */
86
+ assertConfigured() {
87
+ if (this.config.profiles.length === 0)
88
+ throw new UnconfiguredError(getConfigPath());
89
+ }
72
90
  getProfile(profileName) {
91
+ this.assertConfigured();
73
92
  return getConfigProfile(this.config, profileName);
74
93
  }
75
94
  listConnections() {
@@ -42,7 +42,6 @@ export function registerCommandTools({ server, registry, policy }, pipeline) {
42
42
  command: z.string().describe('Command to execute with sudo'),
43
43
  profile: z.string().optional().describe('Profile name'),
44
44
  }, { destructiveHint: true }, async ({ command, profile }, extra) => {
45
- const profileName = defaultProfileName(profile);
46
45
  return runAudited(command, {
47
46
  toolName: 'privileged-command',
48
47
  failureClass: 'privileged',
@@ -51,7 +50,11 @@ export function registerCommandTools({ server, registry, policy }, pipeline) {
51
50
  // Evaluate the bare command too: the sudo wrapper would otherwise
52
51
  // hide a forbidden command inside a quoted `sh -c` argument.
53
52
  preCheck: (cleanCmd) => {
54
- const rawEval = policy.evaluate(cleanCmd, registry.getProfile(profileName), 'privileged-command');
53
+ // Resolved here rather than before `runAudited`: `defaultProfileName` reaches
54
+ // `registry.getProfile()`, which refuses on an unconfigured server, and outside
55
+ // the pipeline's try that refusal escaped without an audit record. `preCheck`
56
+ // runs inside it.
57
+ const rawEval = policy.evaluate(cleanCmd, registry.getProfile(defaultProfileName(profile)), 'privileged-command');
55
58
  if (rawEval.decision === 'deny') {
56
59
  throw new PolicyRefusedError(`POLICY_DENIED: ${rawEval.reason || 'Command not allowed'}`, rawEval);
57
60
  }
@@ -75,7 +75,12 @@ export function createPipeline({ server, registry, policy, audit, approvalGrantT
75
75
  await audit.record({
76
76
  mcpRequestId: ctx.requestId,
77
77
  profile: profileName,
78
- user: registry.getProfile(profileName).user,
78
+ // Looked up without throwing. This used to be `registry.getProfile(profileName).user`,
79
+ // which throws for exactly the reason the tool call is being audited as a failure —
80
+ // an unconfigured server, or a profile name that does not exist — and `auditFailure`
81
+ // swallows that by design. The record the operator most needs was the one record that
82
+ // could not be written.
83
+ user: profileUser(profileName),
79
84
  command,
80
85
  commandClass: evaluation.commandClass,
81
86
  binary: evaluation.binary,
@@ -113,6 +118,10 @@ export function createPipeline({ server, registry, policy, audit, approvalGrantT
113
118
  function defaultProfileName(profile) {
114
119
  return profile || registry.getProfile().name;
115
120
  }
121
+ /** The profile's SSH user, or a placeholder — never throws, so an audit write cannot fail. */
122
+ function profileUser(profileName) {
123
+ return registry.listAllProfiles().find((p) => p.name === profileName)?.user ?? '(unresolved)';
124
+ }
116
125
  function makeProgressSender(extra) {
117
126
  const token = extra?._meta?.progressToken;
118
127
  if (token === undefined)
@@ -135,15 +144,21 @@ export function createPipeline({ server, registry, policy, audit, approvalGrantT
135
144
  */
136
145
  async function runAudited(command, opts, run) {
137
146
  const ctx = makeCtx(opts.extra, opts.profile, opts.session);
138
- const profileName = defaultProfileName(opts.profile);
139
- const profile = registry.getProfile(profileName);
140
147
  const onProgress = makeProgressSender(opts.extra);
141
148
  const abortSignal = opts.extra?.signal;
142
- // Sanitization runs inside the try so a rejected (empty, control-char-only,
143
- // over-length) command still leaves an audit trail — a client probing with
144
- // malformed payloads used to leave none.
149
+ // Named before the try because `auditFailure` needs something to file the record
150
+ // under even when resolution itself is what failed. Reassigned to the resolved name as
151
+ // the first statement inside, so the quota key and the audit record are unchanged on
152
+ // every path that gets that far.
153
+ let profileName = opts.profile ?? '(default)';
154
+ // Profile resolution and sanitization both run inside the try, so a rejected call
155
+ // still leaves an audit trail — a client probing with malformed payloads, or probing
156
+ // a server that has no config at all, used to leave none. The unconfigured refusal
157
+ // reaches this the same way a bad command does.
145
158
  const state = { command };
146
159
  try {
160
+ profileName = defaultProfileName(opts.profile);
161
+ const profile = registry.getProfile(profileName);
147
162
  let effective = opts.synthetic ? command : sanitizeCommand(command, profile.maxChars);
148
163
  state.command = effective;
149
164
  opts.preCheck?.(effective);
@@ -38,6 +38,14 @@ export function registerSessionTools({ server, registry }, pipeline) {
38
38
  const { runAudited, resolveConn, auditResult, auditFailure, makeCtx, defaultProfileName } = pipeline;
39
39
  // ─── list-connections ──────────────────────────────────────────────────
40
40
  server.tool('list-connections', D["list-connections"], {}, { readOnlyHint: true }, async () => {
41
+ // The one tool that reads only `listAllProfiles()` and so never touches
42
+ // `getProfile`. Without this it answered an unconfigured server with a
43
+ // zero-length success — on the tool whose own description sends an agent here
44
+ // first ("discover available hosts before running commands"), and on the one
45
+ // channel where the startup stderr warning is not visible. The agent saw "no
46
+ // hosts" rather than "not configured". Refusing here is what makes the claim in
47
+ // the README, the changeset and the startup warning true of all eleven tools.
48
+ registry.assertConfigured();
41
49
  const connections = registry.listConnections();
42
50
  const profiles = registry.listAllProfiles();
43
51
  const lines = profiles.map((p) => {
@@ -104,19 +112,27 @@ export function registerSessionTools({ server, registry }, pipeline) {
104
112
  profile: z.string().optional().describe('Profile name'),
105
113
  }, { destructiveHint: true }, async ({ name, profile }, extra) => {
106
114
  const cleanName = sanitizeSessionName(name);
107
- const profileName = defaultProfileName(profile);
115
+ // Both the profile name and the connection are resolved inside the try below. They
116
+ // used to be resolved above it, so a refusal — an unconfigured server, an unknown
117
+ // profile name — escaped before `auditFailure` could record that a release was
118
+ // attempted. The placeholders are what the record is filed under if resolution is
119
+ // itself what failed.
120
+ let profileName = profile ?? '(default)';
108
121
  const ctx = makeCtx(extra, profileName, cleanName);
109
- const conn = await resolveConn(profile);
110
- // Recorded with the session's kind, so a remote SIGKILL is greppable in the log and
111
- // distinguishable from ending a local shell — `open-session` encodes its type the
112
- // same way.
113
- // Through `toInfo()` rather than an `instanceof` check: the type is public API, and a
114
- // tool handler reaching for a constructor identity is the layer leak this repo's own
115
- // rules call out.
116
- const kind = conn.getSession(cleanName)?.toInfo().type ?? 'unknown';
117
- const command = `session:close ${kind} ${cleanName}`;
122
+ let command = `session:close unknown ${cleanName}`;
118
123
  const startedAt = Date.now();
119
124
  try {
125
+ profileName = defaultProfileName(profile);
126
+ ctx.profile = profileName;
127
+ const conn = await resolveConn(profile);
128
+ // Recorded with the session's kind, so a remote SIGKILL is greppable in the log and
129
+ // distinguishable from ending a local shell — `open-session` encodes its type the
130
+ // same way.
131
+ // Through `toInfo()` rather than an `instanceof` check: the type is public API, and a
132
+ // tool handler reaching for a constructor identity is the layer leak this repo's own
133
+ // rules call out.
134
+ const kind = conn.getSession(cleanName)?.toInfo().type ?? 'unknown';
135
+ command = `session:close ${kind} ${cleanName}`;
120
136
  const outcome = await conn.closeSession(cleanName);
121
137
  await auditResult(ctx, profileName, command, RELEASE, {
122
138
  stdout: '',
@@ -98,7 +98,8 @@ export async function startHttpServer(createMcpServer, opts) {
98
98
  const httpServer = createServer(async (req, res) => {
99
99
  const url = new URL(req.url || '/', `http://${req.headers.host}`);
100
100
  // Liveness probes are conventionally unauthenticated, and the README lists
101
- // /health without an auth caveat. It exposes nothing beyond "process is up".
101
+ // /health without an auth caveat. It exposes "process is up" and whether any profile
102
+ // is configured — see the handler below for why that second bit is not a secret.
102
103
  const isHealthProbe = req.method === 'GET' && url.pathname === '/health';
103
104
  if (!isHealthProbe) {
104
105
  const auth = req.headers.authorization || '';
@@ -210,8 +211,18 @@ export async function startHttpServer(createMcpServer, opts) {
210
211
  return;
211
212
  }
212
213
  if (req.method === 'GET' && url.pathname === '/health') {
214
+ // `configured` is liveness telling the truth about readiness. Since the server
215
+ // learned to start with nothing configured, an HTTP deployment whose config bind
216
+ // mount silently did not attach comes up, binds the port, and fails 100% of tool
217
+ // calls — while this probe said `healthy: true` and the explaining stderr warning
218
+ // scrolled past at boot. The status stays 200 so an existing probe does not start
219
+ // failing on upgrade; the field is what an operator can alert on.
220
+ //
221
+ // It widens what this unauthenticated route discloses by exactly one bit, and that
222
+ // bit is worth little to anyone: a server with no profile is one that cannot reach
223
+ // any host. `/status` carries the profile list itself and stays behind the token.
213
224
  res.writeHead(200, { 'Content-Type': 'application/json' });
214
- res.end(JSON.stringify({ healthy: true }));
225
+ res.end(JSON.stringify({ healthy: true, configured: registry.listAllProfiles().length > 0 }));
215
226
  return;
216
227
  }
217
228
  res.writeHead(404, { 'Content-Type': 'application/json' });
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "ssh-mcp",
3
3
  "license": "MIT",
4
- "version": "2.3.4",
4
+ "version": "2.4.0",
5
5
  "description": "MCP server exposing policy-gated, audited SSH access for Linux and Windows hosts via the Model Context Protocol.",
6
6
  "mcpName": "io.github.tufantunc/ssh-mcp",
7
7
  "type": "module",