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.
- package/README.md +41 -4
- package/build/config/credential-resolver.js +3 -2
- package/build/config/loader.js +79 -26
- package/build/config/windows-acl.js +786 -0
- package/build/errors.js +116 -0
- package/build/index.js +64 -13
- package/build/policy/engine.js +3 -2
- package/build/ssh/host-key.js +3 -2
- package/build/transport/http.js +10 -4
- package/package.json +2 -1
package/build/errors.js
ADDED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
-
|
|
118
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
|
128
|
-
|
|
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
|
-
|
|
263
|
-
process.exit(1);
|
|
314
|
+
process.exit(reportFatal(error));
|
|
264
315
|
});
|
|
265
316
|
}
|
|
266
317
|
export { parseArgv, buildAppConfig, checkRemovedFlags, parseMaxChars, flagEnabled, resolveHostKeyMode };
|
package/build/policy/engine.js
CHANGED
|
@@ -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
|
|
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
|
|
179
|
+
throw new OperatorError(`Invalid denylist pattern ${JSON.stringify(pattern)}: ${err instanceof Error ? err.message : String(err)}`);
|
|
179
180
|
}
|
|
180
181
|
});
|
|
181
182
|
}
|
package/build/ssh/host-key.js
CHANGED
|
@@ -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
|
|
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
|
|
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);
|
package/build/transport/http.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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.
|
|
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": [
|