ssh-mcp 2.5.1 → 2.7.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.
@@ -1,5 +1,17 @@
1
1
  import { classifyCommand, findForbiddenMatch } from './classifier.js';
2
2
  import { OperatorError } from '../errors.js';
3
+ /**
4
+ * How long to wait for the OPA sidecar before treating it as unavailable.
5
+ *
6
+ * Bounded because an unbounded wait held every tool call for undici's five-minute header
7
+ * timeout. Ten seconds and not two: in the default mode a timeout falls back to the local
8
+ * decision, so the budget is also the price of turning an explicit OPA *deny* into an
9
+ * allow — at two seconds a sidecar under load crosses it by accident, and anyone who can
10
+ * add latency crosses it on purpose. Ten is above a loaded sidecar's p99 and still thirty
11
+ * times cheaper than the hang it replaced. `--opaTimeoutMs` moves it: lower makes the
12
+ * fail-open cheaper to reach, higher makes an outage slower to notice.
13
+ */
14
+ const DEFAULT_OPA_TIMEOUT_MS = 10_000;
3
15
  /** Tiers, most restrictive first. Unknown/unset tiers resolve to the first. */
4
16
  export const HOST_GROUPS = ['prod', 'staging', 'dev'];
5
17
  export const DEFAULT_RULES = {
@@ -184,6 +196,8 @@ export function resolvePolicyRules(profiles, override) {
184
196
  export class PolicyEngine {
185
197
  rules;
186
198
  opaUrl = null;
199
+ opaFailClosed = false;
200
+ opaTimeoutMs = DEFAULT_OPA_TIMEOUT_MS;
187
201
  /** Rate-limits the fail-open warning so one outage can't flood stderr. */
188
202
  lastOpaWarning = 0;
189
203
  /** The operator's patterns, compiled once. The built-ins live in classifier.ts. */
@@ -202,8 +216,15 @@ export class PolicyEngine {
202
216
  }
203
217
  });
204
218
  }
205
- setOpaUrl(url) {
219
+ setOpaUrl(url, failClosed = false, timeoutMs = DEFAULT_OPA_TIMEOUT_MS) {
220
+ this.opaTimeoutMs = timeoutMs;
221
+ // Reset the warning throttle when the mode changes, or the one warning per minute can
222
+ // be the *previous* mode's sentence — "commands OPA would deny may now be allowed"
223
+ // printed while the engine refuses everything.
224
+ if (failClosed !== this.opaFailClosed || url !== this.opaUrl)
225
+ this.lastOpaWarning = 0;
206
226
  this.opaUrl = url;
227
+ this.opaFailClosed = failClosed;
207
228
  }
208
229
  evaluate(command, profile, _toolName) {
209
230
  const parsed = classifyCommand(command);
@@ -277,13 +298,31 @@ export class PolicyEngine {
277
298
  method: 'POST',
278
299
  headers: { 'Content-Type': 'application/json' },
279
300
  body: JSON.stringify({ input }),
301
+ // A sidecar that accepts the connection and never answers is the canonical way
302
+ // OPA is "unreachable", and without this the call ran to undici's five-minute
303
+ // header timeout while every tool call waited on it. The abort lands in the catch
304
+ // below, so both modes reach their own answer inside the budget.
305
+ signal: AbortSignal.timeout(this.opaTimeoutMs),
280
306
  });
281
307
  if (!resp.ok) {
282
- this.warnOpaUnavailable(`HTTP ${resp.status} from ${this.opaUrl}`);
283
- return local;
308
+ return this.onOpaUnavailable(`HTTP ${resp.status}`, local);
284
309
  }
285
- const data = await resp.json();
286
- if (data.result === false) {
310
+ const payload = await resp.json();
311
+ // Guarded, not cast. `as { result?: unknown }` is a lie for a `null` body — reading
312
+ // `.result` off it threw, so the cause on stderr read as a defect in this server
313
+ // rather than as the "no decision" condition it names below.
314
+ const result = typeof payload === 'object' && payload !== null
315
+ ? payload.result
316
+ : undefined;
317
+ if (typeof result !== 'boolean') {
318
+ // A 200 carrying no decision is how OPA answers for an undefined document, which
319
+ // is what a wrong package path, an unactivated bundle, or an `allow` rule written
320
+ // without `default allow := false` all produce. Reading that as consent meant
321
+ // `--opaFailClosed` did not cover the way an OPA gate actually goes down: six of
322
+ // seven response shapes allowed, with no warning at all.
323
+ return this.onOpaUnavailable('no boolean `result` in the response', local);
324
+ }
325
+ if (result === false) {
287
326
  return {
288
327
  decision: 'deny',
289
328
  commandClass: parsed.class,
@@ -294,10 +333,36 @@ export class PolicyEngine {
294
333
  }
295
334
  }
296
335
  catch (err) {
297
- this.warnOpaUnavailable(err instanceof Error ? err.message : String(err));
336
+ return this.onOpaUnavailable(err instanceof Error ? err.message : String(err), local);
298
337
  }
299
338
  return local;
300
339
  }
340
+ /**
341
+ * What an OPA outage means, which is the operator's choice rather than ours.
342
+ *
343
+ * Falling back to the local decision is the default and stays the default: OPA is an
344
+ * additional deny layer, and an outage that stopped all work would be a worse failure
345
+ * than one that narrows the policy. But an operator who deployed OPA *as* the
346
+ * authorization gate has a control that disappears during an outage, and the only signal
347
+ * is a stderr line that MCP clients usually discard. `--opaFailClosed` lets them say
348
+ * that the gate being down means no.
349
+ */
350
+ onOpaUnavailable(cause, local) {
351
+ this.warnOpaUnavailable(cause);
352
+ if (!this.opaFailClosed)
353
+ return local;
354
+ return {
355
+ decision: 'deny',
356
+ commandClass: local.commandClass,
357
+ binary: local.binary,
358
+ ruleId: 'opa-unavailable',
359
+ // Fixed string. `cause` goes to stderr, where the operator is; this `reason` is
360
+ // rethrown as the tool's error text and written to the audit record, and it carried
361
+ // `this.opaUrl` — an internal hostname, and any credential embedded in it — out to
362
+ // the MCP client on every refusal.
363
+ reason: 'OPA evaluation unavailable and --opaFailClosed is set',
364
+ };
365
+ }
301
366
  /**
302
367
  * OPA is an *additional* deny layer, so an outage falls back to the local
303
368
  * decision rather than blocking all work. That is a deliberate trade-off, but
@@ -311,7 +376,9 @@ export class PolicyEngine {
311
376
  return;
312
377
  this.lastOpaWarning = now;
313
378
  console.error(`POLICY WARNING: OPA evaluation unavailable (${cause}). ` +
314
- 'Falling back to local policy — commands OPA would deny may now be allowed.');
379
+ (this.opaFailClosed
380
+ ? 'Refusing every command while it is down, because --opaFailClosed is set.'
381
+ : 'Falling back to local policy — commands OPA would deny may now be allowed.'));
315
382
  }
316
383
  /**
317
384
  * Say which three things produced the refusal, not one of them.
@@ -6,8 +6,8 @@ export const TOOL_DESCRIPTIONS = {
6
6
  'close-session': 'Close a named session. A background session\'s command is signalled on the host (INT, then TERM, then KILL) before its channel is dropped; an interactive session\'s shell is ended. The response says so if the command could not be signalled or had not stopped in time.',
7
7
  'read-session-output': 'Read recent output from a background session (e.g., tail -f logs).',
8
8
  'read-command': 'Execute a READ-ONLY command from an allowlist (ls, cat, grep, find, stat, df, etc.). This tool does NOT modify the system. Prefer this tool for all read operations.',
9
- 'run-command': 'Execute an arbitrary shell command on the remote server. May modify the system. Destructive commands require user approval.',
10
- 'privileged-command': 'Execute a command with sudo elevation. ALWAYS requires user approval. The sudo password is piped via stdin (never visible in process list).',
9
+ 'run-command': 'Execute an arbitrary shell command on the remote server. May modify the system. Commands classified destructive or privileged go through the approval gate; approvalPolicy on the profile decides whether that is a prompt, an automatic allow, or a refusal.',
10
+ 'privileged-command': 'Execute a command with sudo elevation. Goes through the approval gate; approvalPolicy on the profile decides whether that is a prompt, an automatic allow, or a refusal. The sudo password is piped via stdin (never visible in process list).',
11
11
  'sftp-upload': 'Upload a file to the remote server via SFTP (secure file transfer, not shell-based).',
12
12
  'sftp-download': 'Download a file from the remote server via SFTP.',
13
13
  'signal-process': 'Send a signal (INT, TERM, KILL) to a remote process by PID.',
@@ -1,5 +1,6 @@
1
1
  import { createServer } from 'http';
2
2
  import { randomUUID, timingSafeEqual } from 'crypto';
3
+ import { isIP } from 'net';
3
4
  import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
4
5
  import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js';
5
6
  import { SERVER_VERSION } from '../version.js';
@@ -7,32 +8,193 @@ import { OperatorError } from '../errors.js';
7
8
  const MAX_BODY_SIZE = 1_048_576; // 1MB
8
9
  /** Cap on concurrent MCP sessions, so unauthenticated-adjacent churn can't grow the map without bound. */
9
10
  const MAX_SESSIONS = 64;
11
+ /** How many failed auth attempts one client may make per minute. 0 disables the check. */
12
+ export const DEFAULT_AUTH_FAILURE_LIMIT = 10;
13
+ /** Cap on tracked clients, for the reason `MAX_SESSIONS` exists: churn must not grow a map. */
14
+ export const MAX_TRACKED_CLIENTS = 1024;
15
+ const REFILL_INTERVAL_MS = 60_000;
16
+ /**
17
+ * One token-bucket step, shared by the two limiters so the refill arithmetic exists once.
18
+ *
19
+ * Mutates the bucket. Refills proportionally to elapsed time rather than on a timer, so an
20
+ * idle server costs nothing and there is no interval to clean up.
21
+ */
22
+ function consume(bucket, maxTokens) {
23
+ const elapsed = Date.now() - bucket.lastRefill;
24
+ const refilled = Math.floor((elapsed / REFILL_INTERVAL_MS) * maxTokens);
25
+ if (refilled > 0) {
26
+ bucket.tokens = Math.min(maxTokens, bucket.tokens + refilled);
27
+ bucket.lastRefill += Math.round((refilled / maxTokens) * REFILL_INTERVAL_MS);
28
+ }
29
+ if (bucket.tokens > 0) {
30
+ bucket.tokens--;
31
+ return { allowed: true, retryAfterMs: 0 };
32
+ }
33
+ return { allowed: false, retryAfterMs: Math.ceil(REFILL_INTERVAL_MS / maxTokens) };
34
+ }
10
35
  class RateLimiter {
11
- tokens;
12
- lastRefill;
13
36
  maxTokens;
14
- refillIntervalMs;
15
- constructor(maxRequestsPerMinute) {
16
- this.maxTokens = maxRequestsPerMinute;
17
- this.tokens = maxRequestsPerMinute;
18
- this.lastRefill = Date.now();
19
- this.refillIntervalMs = 60_000;
37
+ bucket;
38
+ constructor(maxTokens) {
39
+ this.maxTokens = maxTokens;
40
+ this.bucket = { tokens: maxTokens, lastRefill: Date.now() };
20
41
  }
21
42
  tryConsume() {
22
- const now = Date.now();
23
- const elapsed = now - this.lastRefill;
24
- const refilled = Math.floor((elapsed / this.refillIntervalMs) * this.maxTokens);
25
- if (refilled > 0) {
26
- this.tokens = Math.min(this.maxTokens, this.tokens + refilled);
27
- this.lastRefill += Math.round((refilled / this.maxTokens) * this.refillIntervalMs);
28
- }
29
- if (this.tokens > 0) {
30
- this.tokens--;
43
+ return consume(this.bucket, this.maxTokens);
44
+ }
45
+ }
46
+ /**
47
+ * A bucket per client, for throttling failed authentication.
48
+ *
49
+ * Separate from the request limiter on purpose. The auth check returned before the request
50
+ * limiter was reached, so a wrong bearer token consumed nothing and guessing ran at network
51
+ * speed with no backoff — measured as twelve 401s and zero 429s against `--rateLimit=3`.
52
+ * Moving the request limiter above the auth check would have closed that and opened
53
+ * something worse: the request bucket is global, so unauthenticated traffic could then
54
+ * starve every legitimate client.
55
+ *
56
+ * Only failures consume a token, so a working client never builds a budget up and is never
57
+ * throttled by its own traffic — which is what makes this safe to have on by default. It
58
+ * is not a promise that a correct token always passes: once an address has spent its
59
+ * budget, everything from that address waits, correct tokens included. That is the whole
60
+ * point. Charging only after the comparison would leave the guess itself evaluated and the
61
+ * status code would still tell the attacker which token was right.
62
+ */
63
+ export class AuthFailureLimiter {
64
+ maxTokens;
65
+ buckets = new Map();
66
+ constructor(maxTokens) {
67
+ this.maxTokens = maxTokens;
68
+ }
69
+ /** Whether this client may make another attempt. Does not consume. */
70
+ peek(key) {
71
+ const bucket = this.buckets.get(key);
72
+ if (bucket === undefined)
73
+ return { allowed: true, retryAfterMs: 0 };
74
+ const elapsed = Date.now() - bucket.lastRefill;
75
+ const refilled = Math.floor((elapsed / REFILL_INTERVAL_MS) * this.maxTokens);
76
+ const available = Math.min(this.maxTokens, bucket.tokens + Math.max(refilled, 0));
77
+ if (available > 0)
31
78
  return { allowed: true, retryAfterMs: 0 };
79
+ return { allowed: false, retryAfterMs: Math.ceil(REFILL_INTERVAL_MS / this.maxTokens) };
80
+ }
81
+ /** Charge this client for a failed attempt. */
82
+ recordFailure(key) {
83
+ let bucket = this.buckets.get(key);
84
+ if (bucket === undefined) {
85
+ bucket = { tokens: this.maxTokens, lastRefill: Date.now() };
86
+ if (this.buckets.size >= MAX_TRACKED_CLIENTS) {
87
+ // Evict the *fullest* entry, not the oldest. An exhausted bucket has the oldest
88
+ // `lastRefill` by construction, so evicting by age evicted the locked-out client
89
+ // first and then handed its key a fresh budget: minting enough keys cleared a
90
+ // lockout, which was measured end to end. A full bucket is the one with nothing
91
+ // worth remembering.
92
+ let fullestKey = null;
93
+ let fullest = -1;
94
+ for (const [k, b] of this.buckets) {
95
+ if (b.tokens > fullest) {
96
+ fullest = b.tokens;
97
+ fullestKey = k;
98
+ }
99
+ }
100
+ if (fullestKey !== null)
101
+ this.buckets.delete(fullestKey);
102
+ // Every tracked client is spent, so the table itself is the signal and a new key
103
+ // does not get a full budget.
104
+ //
105
+ // The cost, stated because it is real and cannot be softened here: while the table
106
+ // is saturated an arriving client has one attempt rather than the full budget, so
107
+ // a first typo leaves its correct token waiting. Seeding one token instead of zero
108
+ // looks like it would help and does not — `consume` spends it immediately, and
109
+ // `peek` already allows a key it has never seen, so the first attempt is free
110
+ // either way and the second is refused either way. Measured both, identical.
111
+ // Giving an arriving key a real budget is the refund this rule exists to stop.
112
+ // Saturation needs 1024 addresses that have each spent a full budget.
113
+ if (fullest <= 0)
114
+ bucket = { tokens: 0, lastRefill: Date.now() };
115
+ }
116
+ this.buckets.set(key, bucket);
32
117
  }
33
- const retryAfterMs = Math.ceil(this.refillIntervalMs / this.maxTokens);
34
- return { allowed: false, retryAfterMs };
118
+ consume(bucket, this.maxTokens);
119
+ }
120
+ }
121
+ /**
122
+ * Which client an attempt is charged to.
123
+ *
124
+ * The socket's remote address, which is the real client on a direct connection — how this
125
+ * server is normally run. `X-Forwarded-For` is read only when a proxy is explicitly
126
+ * trusted: honouring it unconditionally would let any client forge its own key and so opt
127
+ * out of the limit entirely.
128
+ *
129
+ * When it is read, the **rightmost** entry is the one taken, not the leftmost. A proxy
130
+ * appends the address it saw, so the rightmost entry is what the nearest trusted proxy
131
+ * observed while every entry to its left came from the client. Reading the leftmost was
132
+ * worse than having no limit: a client sending `X-Forwarded-For: 10.0.0.1` minted a fresh
133
+ * budget per request — measured as nine wrong tokens and zero 429s — and sending a
134
+ * victim's address burned *their* budget instead, locking out a correct token.
135
+ *
136
+ * This assumes exactly one trusted proxy in front, which is what `--trustProxy` means. A
137
+ * chain of two would need the second-from-right, and this does not try to guess the depth.
138
+ * Anything that is not an IP address is discarded rather than used as a map key.
139
+ */
140
+ export function clientKey(req, trustProxy, trustedProxies) {
141
+ const peer = canonicalAddress(req.socket.remoteAddress ?? 'unknown');
142
+ const forwarded = req.headers['x-forwarded-for'];
143
+ if (!trustProxy || forwarded === undefined)
144
+ return { key: peer, forwardedIgnored: false };
145
+ // The rightmost entry is proxy-authored only if a proxy actually appended one. Nothing
146
+ // about the header says whether it did, so the *peer* has to be the proxy — otherwise a
147
+ // client reaching the listener directly sends one forged entry, that entry is the
148
+ // rightmost, and it picks its own key. Both attacks this keying was fixed to stop came
149
+ // back alive in exactly that configuration.
150
+ if (!isTrustedPeer(peer, trustedProxies)) {
151
+ return { key: peer, forwardedIgnored: true };
152
+ }
153
+ const raw = Array.isArray(forwarded) ? forwarded.join(',') : forwarded;
154
+ const entries = raw.split(',').map((e) => e.trim()).filter(Boolean);
155
+ const nearest = entries[entries.length - 1];
156
+ const address = nearest === undefined ? undefined : forwardedAddress(nearest);
157
+ if (address === undefined)
158
+ return { key: peer, forwardedIgnored: true };
159
+ return { key: address, forwardedIgnored: false };
160
+ }
161
+ /**
162
+ * Whether the peer may speak for other clients.
163
+ *
164
+ * Bare `--trustProxy` means a loopback peer, which is the deployment the README asks for:
165
+ * the server binds to 127.0.0.1 and a proxy on the same host terminates TLS. A proxy
166
+ * somewhere else has to be named, because "trust whoever connected" is the assumption that
167
+ * made the header forgeable again.
168
+ */
169
+ function isTrustedPeer(peer, trustedProxies) {
170
+ if (trustedProxies !== undefined && trustedProxies.length > 0) {
171
+ return trustedProxies.includes(peer);
172
+ }
173
+ return peer === '127.0.0.1' || peer === '::1' || peer.startsWith('127.');
174
+ }
175
+ /**
176
+ * One `X-Forwarded-For` entry as an address, or undefined if it is not one.
177
+ *
178
+ * `net.isIP` rejects the bracketed and port-suffixed spellings, which is how IPv6 usually
179
+ * appears in this header — and rejecting silently collapsed every client onto the proxy's
180
+ * key, which is worse than not keying at all.
181
+ */
182
+ function forwardedAddress(entry) {
183
+ let candidate = entry;
184
+ const bracketed = /^\[([^\]]+)\](?::\d+)?$/.exec(candidate);
185
+ if (bracketed)
186
+ candidate = bracketed[1];
187
+ // A trailing `:port` only when what is left is not itself an IPv6 literal: `::1` has
188
+ // colons of its own and must not be truncated.
189
+ else if (isIP(candidate) === 0 && /^[^:]+:\d+$/.test(candidate)) {
190
+ candidate = candidate.slice(0, candidate.lastIndexOf(':'));
35
191
  }
192
+ candidate = canonicalAddress(candidate);
193
+ return isIP(candidate) === 0 ? undefined : candidate;
194
+ }
195
+ /** `::ffff:127.0.0.1` and `127.0.0.1` are the same client; key them the same way. */
196
+ function canonicalAddress(address) {
197
+ return address.startsWith('::ffff:') ? address.slice(7) : address;
36
198
  }
37
199
  function jsonRpcError(res, status, code, message) {
38
200
  res.writeHead(status, { 'Content-Type': 'application/json' });
@@ -49,6 +211,26 @@ export async function startHttpServer(createMcpServer, opts) {
49
211
  throw new OperatorError('HTTP transport requires --bearerToken. Example: --transport=http --bearerToken=secret\n' +
50
212
  'Without authentication, any network client can execute SSH commands on your hosts.');
51
213
  }
214
+ const authFailureLimit = opts.authFailureLimit ?? DEFAULT_AUTH_FAILURE_LIMIT;
215
+ const authFailureLimiter = authFailureLimit > 0
216
+ ? new AuthFailureLimiter(authFailureLimit)
217
+ : null;
218
+ // Said once, when it turns out to matter. Behind a proxy with `--trustProxy` off, every
219
+ // client is keyed on the proxy's socket address and so shares one budget — which means
220
+ // ten failures from anyone locks out everyone. The README tells operators to terminate
221
+ // TLS at a proxy, so this is the configuration it recommends, and the collapse is
222
+ // invisible until a legitimate client is refused.
223
+ let warnedSharedBudget = false;
224
+ const warnSharedBudget = () => {
225
+ if (warnedSharedBudget)
226
+ return;
227
+ warnedSharedBudget = true;
228
+ console.error('POLICY WARNING: X-Forwarded-For is present but not being used to tell clients ' +
229
+ 'apart, so every client shares one failed-auth budget and one failing client can ' +
230
+ 'lock out the rest. Either --trustProxy is off, or the peer is not a trusted proxy ' +
231
+ '(bare --trustProxy trusts a loopback peer; name others with --trustedProxies), or ' +
232
+ 'the rightmost entry is not an address this server can read.');
233
+ };
52
234
  const rateLimiter = opts.rateLimit && opts.rateLimit > 0
53
235
  ? new RateLimiter(opts.rateLimit)
54
236
  : null;
@@ -102,6 +284,41 @@ export async function startHttpServer(createMcpServer, opts) {
102
284
  // is configured — see the handler below for why that second bit is not a secret.
103
285
  const isHealthProbe = req.method === 'GET' && url.pathname === '/health';
104
286
  if (!isHealthProbe) {
287
+ // Checked before the token is compared, not after — so an exhausted budget answers
288
+ // 429 without evaluating the guess. Gating only the 401 path instead would throttle
289
+ // nothing: the comparison would still happen and a correct token would still be
290
+ // served, so the status code would still tell an attacker which guess was right.
291
+ let key = '';
292
+ if (authFailureLimiter) {
293
+ const resolved = clientKey(req, opts.trustProxy === true, opts.trustedProxies);
294
+ key = resolved.key;
295
+ // Warned in both directions. Without `--trustProxy` a proxied deployment shares
296
+ // one budget; *with* it, an entry that could not be read leaves the same collapse
297
+ // in place, and that case used to be the silent one — the operator had set the
298
+ // flag and had no way to know it was not taking effect.
299
+ if (resolved.forwardedIgnored || (opts.trustProxy !== true && req.headers['x-forwarded-for'])) {
300
+ warnSharedBudget();
301
+ }
302
+ }
303
+ if (authFailureLimiter) {
304
+ const { allowed, retryAfterMs } = authFailureLimiter.peek(key);
305
+ if (!allowed) {
306
+ const retryAfterSec = Math.ceil(retryAfterMs / 1000);
307
+ res.writeHead(429, {
308
+ 'Content-Type': 'application/json',
309
+ 'Retry-After': String(retryAfterSec),
310
+ });
311
+ res.end(JSON.stringify({
312
+ jsonrpc: '2.0',
313
+ error: {
314
+ code: -32604,
315
+ message: `Too many failed authentication attempts. Retry after ${retryAfterSec}s.`,
316
+ },
317
+ id: null,
318
+ }));
319
+ return;
320
+ }
321
+ }
105
322
  const auth = req.headers.authorization || '';
106
323
  const expected = `Bearer ${bearerToken}`;
107
324
  const authBuf = Buffer.from(auth);
@@ -109,6 +326,7 @@ export async function startHttpServer(createMcpServer, opts) {
109
326
  const match = authBuf.length === expectedBuf.length &&
110
327
  timingSafeEqual(authBuf, expectedBuf);
111
328
  if (!match) {
329
+ authFailureLimiter?.recordFailure(key);
112
330
  // RFC 7235: a 401 must say how to authenticate. MCP clients also parse
113
331
  // JSON-RPC envelopes on this route, so 401 speaks the same dialect as
114
332
  // the 429 and 413 responses rather than a bare {error}.
@@ -248,6 +466,9 @@ export async function startHttpServer(createMcpServer, opts) {
248
466
  if (rateLimiter) {
249
467
  console.error(`Rate limit: ${opts.rateLimit} req/min`);
250
468
  }
469
+ if (authFailureLimiter) {
470
+ console.error(`Auth failure limit: ${authFailureLimit}/min per client`);
471
+ }
251
472
  resolve();
252
473
  });
253
474
  });
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "ssh-mcp",
3
3
  "license": "MIT",
4
- "version": "2.5.1",
4
+ "version": "2.7.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",
@@ -38,8 +38,8 @@
38
38
  "zod": "^4.4.3"
39
39
  },
40
40
  "devDependencies": {
41
- "@changesets/changelog-github": "^0.7.0",
42
- "@changesets/cli": "^2.31.0",
41
+ "@changesets/changelog-github": "^1.0.0",
42
+ "@changesets/cli": "^3.0.1",
43
43
  "@types/node": "^26.2.0",
44
44
  "@types/ssh2": "^1.15.5",
45
45
  "@vitest/coverage-v8": "^4.1.10",