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/build/index.js CHANGED
@@ -1,222 +1,14 @@
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, getConfigPath } from './config/loader.js';
5
- import { OperatorError, ConfigNotFoundError, reportFatal } from './errors.js';
4
+ import { reportFatal } from './errors.js';
6
5
  import { ConnectionRegistry } from './ssh/connection-registry.js';
7
- import { PolicyEngine, HOST_GROUPS, resolvePolicyRules } from './policy/engine.js';
6
+ import { PolicyEngine, resolvePolicyRules } from './policy/engine.js';
8
7
  import { AuditStore } from './audit/store.js';
9
8
  import { registerTools, registerResources, getToolHashes } from './tools/registry.js';
10
9
  import { initKeychain } from './config/credential-resolver.js';
11
10
  import { SERVER_VERSION } from './version.js';
12
- function parseArgv() {
13
- const args = process.argv.slice(2);
14
- const config = {};
15
- for (const arg of args) {
16
- if (arg.startsWith('--')) {
17
- const eq = arg.indexOf('=');
18
- if (eq === -1) {
19
- config[arg.slice(2)] = null;
20
- }
21
- else {
22
- config[arg.slice(2, eq)] = arg.slice(eq + 1);
23
- }
24
- }
25
- }
26
- return config;
27
- }
28
- /**
29
- * Whether a boolean flag was passed at all.
30
- *
31
- * `parseArgv` stores `null` for a flag written without `=`, which is how every
32
- * documented boolean flag is written. Testing that value for truthiness makes
33
- * the flag a no-op: `--disableApproval`, `--auditEntropyScan` and
34
- * `--auditTamperEvident` all did nothing unless spelled `--flag=1`, which
35
- * nothing documents (#91). The two call sites that got it right already used
36
- * `!== undefined`; this gives the check a name so the next one cannot miss it.
37
- *
38
- * `--flag=false` and `--flag=0` turn it off, because a flag that cannot be
39
- * turned off once written into a wrapper script is its own annoyance.
40
- */
41
- function flagEnabled(argv, name) {
42
- if (!(name in argv))
43
- return false;
44
- const value = argv[name];
45
- if (value === null || value === '')
46
- return true;
47
- return !['false', '0', 'no', 'off'].includes(value.toLowerCase());
48
- }
49
- /**
50
- * v1 flags that v2 removed. Silently ignoring them means the user only finds
51
- * out at the first command, as an auth failure with no hint about the cause.
52
- */
53
- const REMOVED_V1_FLAGS = {
54
- password: 'Use the SSH_MCP_PASSWORD env var (or a per-profile SSH_MCP_<PROFILE>_PASSWORD).',
55
- suPassword: 'Use the SSH_MCP_SUDO_PASSWORD env var.',
56
- sudoPassword: 'Use the SSH_MCP_SUDO_PASSWORD env var.',
57
- disableSudo: 'Sudo is now a separate tool; restrict it with a role/policy that disallows the "privileged" class.',
58
- };
59
- function checkRemovedFlags(argv) {
60
- const found = Object.keys(REMOVED_V1_FLAGS).filter((f) => f in argv);
61
- if (found.length === 0)
62
- return;
63
- throw new OperatorError('These flags were removed in v2:\n' +
64
- found.map((f) => ` --${f}: ${REMOVED_V1_FLAGS[f]}`).join('\n') +
65
- '\nSee the "Migrating from v1" section of the README.');
66
- }
67
- /**
68
- * v1 documented `--maxChars=none` (and 0/negative) as "no limit". Parsing it
69
- * with `parseInt(...) || 5000` silently turned that into a 5000-char cap.
70
- */
71
- function parseMaxChars(raw) {
72
- if (typeof raw !== 'string' || raw === '')
73
- return 5_000;
74
- if (raw.toLowerCase() === 'none')
75
- return Number.MAX_SAFE_INTEGER;
76
- const parsed = parseInt(raw);
77
- if (isNaN(parsed))
78
- return 5_000;
79
- return parsed <= 0 ? Number.MAX_SAFE_INTEGER : parsed;
80
- }
81
- /**
82
- * `strict` was previously unreachable: nothing but test code could select it,
83
- * yet host-key.ts pointed users at a `--acceptNewHostKey` flag that never
84
- * existed. `--insecureHostKey` is kept as an alias for the documented flag.
85
- */
86
- function resolveHostKeyMode(argv) {
87
- if (flagEnabled(argv, 'insecureHostKey'))
88
- return 'insecure';
89
- const mode = argv.hostKeyMode;
90
- if (mode === null || mode === undefined)
91
- return 'tofu';
92
- if (mode === 'tofu' || mode === 'strict' || mode === 'insecure')
93
- return mode;
94
- throw new OperatorError(`Invalid --hostKeyMode=${mode}. Expected one of: tofu, strict, insecure.`);
95
- }
96
- /**
97
- * Which policy tier a CLI-configured host belongs to.
98
- *
99
- * Without this there was no way to say it from the command line at all. The
100
- * inline profile carries no group, so it fell to the strictest tier, where the
101
- * `admin` role has no `privileged` — meaning `sudo` could never run for anyone
102
- * who had not written a config file (#91).
103
- *
104
- * The default stays `prod`. Guessing an unknown host is production is the safe
105
- * direction; what was missing was a way to correct the guess.
106
- */
107
- export function resolveHostGroup(argv) {
108
- const group = argv.group;
109
- if (group === null || group === undefined)
110
- return 'prod';
111
- if (HOST_GROUPS.includes(group))
112
- return group;
113
- // Falling through would silently apply the prod bindings to a typo, and the
114
- // operator would read the refusal as policy rather than as their own slip.
115
- throw new OperatorError(`Invalid --group=${group}. Expected one of: ${HOST_GROUPS.join(', ')}.`);
116
- }
117
- /**
118
- * Every ACL finding, in the order it happened — both "readable beyond its owner" and
119
- * "could not be checked".
120
- *
121
- * Collected rather than only printed so `main()` can act on them once there is somewhere
122
- * durable to put them; see the note at the sink below.
123
- */
124
- const aclFindings = [];
125
- async function buildAppConfig(argv) {
126
- // The way past an ACL that could not be read, rather than an operator with no
127
- // exit. See assertPrivateOnWindows.
128
- // The sink is supplied here rather than left to the module's default, which is the
129
- // point of having one: "this security check did not run" is a decision about what the
130
- // operator must be told, and the loader is not the layer that owns that. Routing it to
131
- // the audit store as well needs an AuditRecord variant for a startup event — that type
132
- // is hash-chained and tamper-evident, so it is its own change, and it is queued.
133
- const aclOpts = {
134
- enforce: flagEnabled(argv, 'strictConfigAcl'),
135
- allowUnchecked: flagEnabled(argv, 'allowUncheckedConfigAcl'),
136
- onFinding: (f) => {
137
- aclFindings.push(f);
138
- console.error(`Warning: ${f.message}`);
139
- },
140
- };
141
- if (argv.config !== undefined) {
142
- // `parseArgv` stores null for a flag written without `=`, so a bare `--config` used to
143
- // be falsy here and fall into the default-path branch — which now refuses rather than
144
- // silently ignoring an unusable file, so the operator got a refusal naming a path they
145
- // never asked for.
146
- if (argv.config === null || argv.config === '') {
147
- throw new OperatorError('--config needs a path: --config=<path>');
148
- }
149
- return loadConfig(argv.config, aclOpts);
150
- }
151
- try {
152
- const fromFile = await loadConfig(undefined, aclOpts);
153
- // Before #138 every Windows config was rejected by the mode-bit check and
154
- // the rejection was swallowed, so an operator who also passed --host/--user
155
- // was silently running off the flags. Now that the file loads, it takes
156
- // precedence — a flip worth one line rather than a surprise about which host
157
- // a command reached.
158
- if (argv.host || argv.user) {
159
- console.error(`Note: ${getConfigPath()} was loaded, so --host/--user are ignored. ` +
160
- 'Pass --config <path> to choose a different file.');
161
- }
162
- return fromFile;
163
- }
164
- catch (err) {
165
- // Only "there is no file" means fall through. A file that exists and is
166
- // unusable — malformed TOML, a schema violation, a permission failure —
167
- // used to be swallowed here too and reported as a missing config, which is
168
- // how #138 stayed unfindable: the operator's file was in the right place,
169
- // and the real error was discarded before anyone could read it.
170
- //
171
- // Falling through would also be wrong on its own terms whenever
172
- // --host/--user happen to be present: the server would start, silently
173
- // ignoring every profile, policy and role binding the operator wrote.
174
- if (!(err instanceof ConfigNotFoundError))
175
- throw err;
176
- }
177
- if (!argv.host || !argv.user) {
178
- throw new OperatorError('No config file found and missing required --host/--user.\n' +
179
- // Was hardcoded to ~/.config/ssh-mcp/config.toml on every platform, so on
180
- // Windows and macOS it named a path this code never reads.
181
- `Either create a config file at ${getConfigPath()} or pass --config <path>.\n` +
182
- 'For quick start: --host=<host> --user=<user> (credentials via env vars).');
183
- }
184
- const defaults = {
185
- sessionMaxPerConnection: parseInt(argv.sessionMax) || 5,
186
- sessionIdleTimeoutMs: parseInt(argv.sessionTtl) || 600_000,
187
- sessionBackgroundMaxMs: 3_600_000,
188
- commandTimeoutMs: parseInt(argv.timeout) || 60_000,
189
- commandMaxChars: parseMaxChars(argv.maxChars),
190
- commandMaxOutputBytes: 1_048_576,
191
- connectionIdleReapMs: 900_000,
192
- commandQuotaPerDay: parseInt(argv.commandQuota) || 0,
193
- approvalGrantTtlMs: parseInt(argv.approvalGrantTtl) || 0,
194
- approvalMode: 'ask-destructive',
195
- };
196
- const profile = {
197
- name: 'default',
198
- host: argv.host,
199
- port: parseInt(argv.port) || 22,
200
- user: argv.user,
201
- auth: argv.key ? 'key' : 'password',
202
- keyRef: argv.key || undefined,
203
- workdir: argv.workdir || undefined,
204
- tty: false,
205
- timeout: defaults.commandTimeoutMs,
206
- maxChars: defaults.commandMaxChars,
207
- maxOutputBytes: defaults.commandMaxOutputBytes,
208
- role: 'admin',
209
- group: resolveHostGroup(argv),
210
- readOnly: false,
211
- approvalPolicy: flagEnabled(argv, 'disableApproval') ? 'auto' : defaults.approvalMode,
212
- cert: false,
213
- sessionMaxPerConnection: defaults.sessionMaxPerConnection,
214
- sessionIdleTimeoutMs: defaults.sessionIdleTimeoutMs,
215
- sessionBackgroundMaxMs: defaults.sessionBackgroundMaxMs,
216
- commandQuotaPerDay: defaults.commandQuotaPerDay,
217
- };
218
- return { defaults, profiles: [profile] };
219
- }
11
+ import { parseArgv, buildAppConfig, checkRemovedFlags, flagEnabled, resolveHostKeyMode, } from './cli.js';
220
12
  async function main() {
221
13
  const argv = parseArgv();
222
14
  checkRemovedFlags(argv);
@@ -342,4 +134,3 @@ if (isCliEnabled || isTestMode) {
342
134
  process.exit(reportFatal(error));
343
135
  });
344
136
  }
345
- export { parseArgv, buildAppConfig, checkRemovedFlags, parseMaxChars, flagEnabled, resolveHostKeyMode };
@@ -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,8 +1,9 @@
1
1
  {
2
2
  "name": "ssh-mcp",
3
3
  "license": "MIT",
4
- "version": "2.3.3",
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
+ "mcpName": "io.github.tufantunc/ssh-mcp",
6
7
  "type": "module",
7
8
  "bin": {
8
9
  "ssh-mcp": "build/index.js"
@@ -13,7 +14,7 @@
13
14
  "typecheck": "tsc --noEmit -p tsconfig.test.json",
14
15
  "inspect": "npx @modelcontextprotocol/inspector node build/index.js",
15
16
  "changeset": "changeset",
16
- "version": "changeset version",
17
+ "version": "changeset version && node scripts/sync-server-json.mjs",
17
18
  "test": "cross-env SSH_MCP_DISABLE_MAIN=1 vitest --run",
18
19
  "test:unit": "cross-env SSH_MCP_DISABLE_MAIN=1 vitest --run test/unit/ test/property/",
19
20
  "test:integration": "cross-env SSH_MCP_DISABLE_MAIN=1 vitest --run test/integration/",