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 +16 -0
- package/build/cli.js +259 -0
- package/build/config/loader.js +9 -1
- package/build/config/sddl.js +453 -0
- package/build/config/windows-acl.js +1 -423
- package/build/errors.js +23 -0
- package/build/index.js +3 -212
- package/build/ssh/connection-registry.js +20 -1
- package/build/tools/command-tools.js +5 -2
- package/build/tools/pipeline.js +21 -6
- package/build/tools/session-tools.js +26 -10
- package/build/transport/http.js +13 -2
- package/package.json +3 -2
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
|
+
}
|
package/build/config/loader.js
CHANGED
|
@@ -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);
|