ssh-mcp 1.5.0 → 2.0.1

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.
@@ -0,0 +1,185 @@
1
+ import { tracer } from '../observability/tracer.js';
2
+ import { sanitizeCommand } from '../guard/sanitizer.js';
3
+ import { requestApproval } from '../guard/elicitation.js';
4
+ import { commandOutput } from './results.js';
5
+ import { CommandQuota } from '../policy/quota.js';
6
+ import { ApprovalGrants } from '../guard/approval-grants.js';
7
+ /** Policy never ran — the input was rejected at the boundary. */
8
+ function rejectedEvaluation(commandClass) {
9
+ return { decision: 'deny', commandClass, binary: '', ruleId: 'input-rejected' };
10
+ }
11
+ /**
12
+ * Carries the policy decision that caused a refusal, so the audit record shows
13
+ * the real rule and class instead of a synthetic placeholder.
14
+ */
15
+ export class PolicyRefusedError extends Error {
16
+ evaluation;
17
+ constructor(message, evaluation) {
18
+ super(message);
19
+ this.evaluation = evaluation;
20
+ this.name = 'PolicyRefusedError';
21
+ }
22
+ }
23
+ /**
24
+ * Builds the audited execution pipeline every tool handler runs through.
25
+ *
26
+ * A factory rather than free functions because the pipeline closes over the
27
+ * server (approval prompts), registry (connections and profiles), policy engine
28
+ * and audit store. Tool groups receive the result and never touch those four
29
+ * directly, so there is exactly one path from caller input to a remote command.
30
+ */
31
+ export function createPipeline({ server, registry, policy, audit, approvalGrantTtlMs = 0 }) {
32
+ const quota = new CommandQuota();
33
+ const grants = new ApprovalGrants(approvalGrantTtlMs);
34
+ async function resolveConn(profileName) {
35
+ return registry.getOrCreate(profileName);
36
+ }
37
+ async function checkPolicyAndApprove(command, profileName, toolName) {
38
+ const span = tracer.startSpan('policy.evaluate');
39
+ span.setAttribute('tool.name', toolName);
40
+ span.setAttribute('ssh.profile', profileName);
41
+ try {
42
+ const conn = await resolveConn(profileName);
43
+ const evaluation = await policy.evaluateWithOpa(command, conn.profile, toolName);
44
+ span.setAttribute('policy.decision', evaluation.decision);
45
+ span.setAttribute('command.class', evaluation.commandClass);
46
+ span.setAttribute('command.binary', evaluation.binary);
47
+ if (evaluation.decision === 'deny') {
48
+ throw new PolicyRefusedError(`POLICY_DENIED: ${evaluation.reason || 'Command not allowed'}`, evaluation);
49
+ }
50
+ if (evaluation.decision === 'require-approval') {
51
+ // A live grant from an earlier explicit approval of this exact command.
52
+ if (grants.has(conn.profile.name, command, evaluation.commandClass)) {
53
+ span.setAttribute('policy.grant', 'reused');
54
+ return { conn, evaluation, approver: 'jit-grant' };
55
+ }
56
+ const approval = await requestApproval(server, command, conn.profile.name, evaluation);
57
+ if (!approval.approved) {
58
+ throw new PolicyRefusedError('APPROVAL_DENIED: User did not approve this command', evaluation);
59
+ }
60
+ grants.record(conn.profile.name, command, evaluation.commandClass);
61
+ return { conn, evaluation, approver: approval.approver };
62
+ }
63
+ return { conn, evaluation, approver: undefined };
64
+ }
65
+ finally {
66
+ span.end();
67
+ }
68
+ }
69
+ async function auditResult(ctx, profileName, command, evaluation, result, approver) {
70
+ await audit.record({
71
+ mcpRequestId: ctx.requestId,
72
+ profile: profileName,
73
+ user: registry.getProfile(profileName).user,
74
+ command,
75
+ commandClass: evaluation.commandClass,
76
+ binary: evaluation.binary,
77
+ decision: evaluation.decision,
78
+ ruleId: evaluation.ruleId,
79
+ exitCode: 'exitCode' in result ? result.exitCode : undefined,
80
+ durationMs: 'durationMs' in result ? result.durationMs : undefined,
81
+ error: 'error' in result ? result.error : undefined,
82
+ approver,
83
+ });
84
+ }
85
+ function makeCtx(extra, profile, session) {
86
+ return { requestId: extra?.requestId ?? 0, profile, session };
87
+ }
88
+ /**
89
+ * Audit a failed tool call with the real policy decision when there was one.
90
+ *
91
+ * Never throws: an audit write error must not replace the error the caller
92
+ * actually needs to see (and on the success path, must not make an agent
93
+ * think a non-idempotent command failed after it already ran).
94
+ */
95
+ async function auditFailure(ctx, profileName, state, failureClass, err) {
96
+ const evaluation = (err instanceof PolicyRefusedError ? err.evaluation : undefined)
97
+ ?? state.evaluation
98
+ ?? rejectedEvaluation(failureClass);
99
+ try {
100
+ await auditResult(ctx, profileName, state.command, evaluation, {
101
+ error: err?.message ?? String(err),
102
+ });
103
+ }
104
+ catch (auditErr) {
105
+ console.error('Audit write failed while recording a tool failure:', auditErr);
106
+ }
107
+ }
108
+ function defaultProfileName(profile) {
109
+ return profile || registry.getProfile().name;
110
+ }
111
+ function makeProgressSender(extra) {
112
+ const token = extra?._meta?.progressToken;
113
+ if (token === undefined)
114
+ return undefined;
115
+ return (bytes, tail) => {
116
+ extra.sendNotification({
117
+ method: 'notifications/progress',
118
+ params: { progressToken: token, progress: bytes, message: tail },
119
+ }).catch(() => { });
120
+ };
121
+ }
122
+ /**
123
+ * The one place the sanitize → policy → approve → run → audit pipeline lives.
124
+ *
125
+ * Five of the six audited handlers used to inline their own copy of this, and
126
+ * they had already drifted apart: one dropped abort/progress support, one
127
+ * audited a different command string on success than on failure, one carried
128
+ * a dead `?? command` fallback. Every audit-semantics fix had to be made six
129
+ * times, so they were never all correct at once.
130
+ */
131
+ async function runAudited(command, opts, run) {
132
+ const ctx = makeCtx(opts.extra, opts.profile, opts.session);
133
+ const profileName = defaultProfileName(opts.profile);
134
+ const profile = registry.getProfile(profileName);
135
+ const onProgress = makeProgressSender(opts.extra);
136
+ const abortSignal = opts.extra?.signal;
137
+ // Sanitization runs inside the try so a rejected (empty, control-char-only,
138
+ // over-length) command still leaves an audit trail — a client probing with
139
+ // malformed payloads used to leave none.
140
+ const state = { command };
141
+ try {
142
+ let effective = opts.synthetic ? command : sanitizeCommand(command, profile.maxChars);
143
+ state.command = effective;
144
+ opts.preCheck?.(effective);
145
+ if (opts.wrap) {
146
+ effective = opts.wrap(effective);
147
+ state.command = effective;
148
+ }
149
+ const { conn, evaluation, approver } = await checkPolicyAndApprove(effective, profileName, opts.toolName);
150
+ state.evaluation = evaluation;
151
+ if (opts.enforceClass && evaluation.commandClass !== opts.enforceClass) {
152
+ throw new Error(`${opts.toolName} only accepts ${opts.enforceClass} commands, got: ${evaluation.commandClass}`);
153
+ }
154
+ // Counted after the policy allowed it and before it runs: a denied
155
+ // command should not burn quota, and an allowed one should be counted
156
+ // even if it later fails on the host — the work was still spent.
157
+ const budget = quota.consume(profileName, profile.commandQuotaPerDay);
158
+ if (!budget.allowed) {
159
+ throw new PolicyRefusedError(`QUOTA_EXCEEDED: profile "${profileName}" has used its ${profile.commandQuotaPerDay} commands ` +
160
+ `for the last 24h. Next slot frees at ${budget.retryAt?.toISOString()}.`, { ...evaluation, decision: 'deny', ruleId: 'command-quota' });
161
+ }
162
+ const { audited, output } = await run({
163
+ conn, command: effective, profileName, onProgress, abortSignal, extra: opts.extra,
164
+ });
165
+ await auditResult(ctx, profileName, effective, evaluation, audited, approver);
166
+ return output;
167
+ }
168
+ catch (err) {
169
+ await auditFailure(ctx, profileName, state, opts.failureClass, err);
170
+ throw err;
171
+ }
172
+ }
173
+ /** Run a command over the connection and report it verbatim to the client. */
174
+ function execAndReport(opts = {}) {
175
+ return async (rt) => {
176
+ const audited = await rt.conn.exec(rt.command, {
177
+ stdin: opts.stdin,
178
+ onProgress: rt.onProgress,
179
+ abortSignal: rt.abortSignal,
180
+ });
181
+ return { audited, output: commandOutput(audited) };
182
+ };
183
+ }
184
+ return { runAudited, execAndReport, defaultProfileName, makeCtx, auditResult, auditFailure, resolveConn, quota, grants };
185
+ }
@@ -0,0 +1,23 @@
1
+ import { createPipeline } from './pipeline.js';
2
+ import { registerSessionTools } from './session-tools.js';
3
+ import { registerCommandTools } from './command-tools.js';
4
+ import { registerFileTools } from './file-tools.js';
5
+ // Re-exported so existing importers keep a single entry point for the tool layer.
6
+ export { TOOL_DESCRIPTIONS, getToolHashes } from './descriptions.js';
7
+ export { registerResources } from './resources.js';
8
+ /**
9
+ * Wire every MCP tool onto the server.
10
+ *
11
+ * The handlers live in three groups by subject (sessions, commands, files) and
12
+ * share one audited pipeline, built here. Splitting them up is what keeps the
13
+ * pipeline the only route from caller input to a remote command: a handler that
14
+ * wanted to bypass policy or audit would have to reach past `pipeline` for the
15
+ * registry, which it is not given.
16
+ */
17
+ export function registerTools(server, registry, policy, audit, opts = {}) {
18
+ const deps = { server, registry, policy, audit, ...opts };
19
+ const pipeline = createPipeline(deps);
20
+ registerSessionTools(deps, pipeline);
21
+ registerCommandTools(deps, pipeline);
22
+ registerFileTools(deps, pipeline);
23
+ }
@@ -0,0 +1,73 @@
1
+ import { ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ export function registerResources(server, registry) {
3
+ function jsonResource(uri, data) {
4
+ return {
5
+ contents: [{
6
+ uri,
7
+ mimeType: 'application/json',
8
+ text: JSON.stringify(data, null, 2),
9
+ }],
10
+ };
11
+ }
12
+ server.resource('connections', 'ssh://connections', { description: 'List all SSH profiles and their connection status', mimeType: 'application/json' }, async () => {
13
+ const connections = registry.listConnections();
14
+ const profiles = registry.listAllProfiles().map((p) => {
15
+ const conn = connections.find((c) => c.profile === p.name);
16
+ return {
17
+ name: p.name,
18
+ host: p.host,
19
+ port: p.port,
20
+ user: p.user,
21
+ role: p.role,
22
+ readOnly: p.readOnly,
23
+ approvalPolicy: p.approvalPolicy,
24
+ status: conn?.status || 'disconnected',
25
+ sessions: conn?.sessionCount || 0,
26
+ };
27
+ });
28
+ return jsonResource('ssh://connections', { profiles });
29
+ });
30
+ server.resource('profile', new ResourceTemplate('ssh://connections/{profile}', { list: undefined }), { description: 'Get details for a specific SSH profile', mimeType: 'application/json' }, async (uri, variables) => {
31
+ const profileName = variables.profile;
32
+ const profile = registry.listAllProfiles().find((p) => p.name === profileName);
33
+ if (!profile) {
34
+ return jsonResource(uri.href, { error: `Profile "${profileName}" not found` });
35
+ }
36
+ const conn = registry.get(profileName);
37
+ const sessions = conn?.listSessions().map((s) => s.toInfo()) || [];
38
+ return jsonResource(uri.href, {
39
+ profile: {
40
+ name: profile.name,
41
+ host: profile.host,
42
+ port: profile.port,
43
+ user: profile.user,
44
+ role: profile.role,
45
+ readOnly: profile.readOnly,
46
+ auth: profile.auth,
47
+ approvalPolicy: profile.approvalPolicy,
48
+ via: profile.via,
49
+ workdir: profile.workdir,
50
+ tty: profile.tty,
51
+ timeout: profile.timeout,
52
+ maxChars: profile.maxChars,
53
+ sessionMaxPerConnection: profile.sessionMaxPerConnection,
54
+ sessionIdleTimeoutMs: profile.sessionIdleTimeoutMs,
55
+ },
56
+ connection: conn?.toInfo() || null,
57
+ sessions,
58
+ });
59
+ });
60
+ server.resource('session', new ResourceTemplate('ssh://sessions/{profile}/{session}', { list: undefined }), { description: 'Get metadata for a specific session', mimeType: 'application/json' }, async (uri, variables) => {
61
+ const profileName = variables.profile;
62
+ const sessionName = variables.session;
63
+ const conn = registry.get(profileName);
64
+ if (!conn) {
65
+ return jsonResource(uri.href, { error: `Profile "${profileName}" not connected` });
66
+ }
67
+ const session = conn.getSession(sessionName);
68
+ if (!session) {
69
+ return jsonResource(uri.href, { error: `Session "${sessionName}" not found on ${profileName}` });
70
+ }
71
+ return jsonResource(uri.href, session.toInfo());
72
+ });
73
+ }
@@ -0,0 +1,31 @@
1
+ import { redactText } from '../guard/redactor.js';
2
+ export function syntheticSuccess(profile) {
3
+ return { exitCode: 0, stdout: '', stderr: '', durationMs: 0, profile };
4
+ }
5
+ export function textResult(text) {
6
+ return { content: [{ type: 'text', text }] };
7
+ }
8
+ /**
9
+ * Render a CommandResult for the client. A non-zero exit (or a kill signal)
10
+ * is reported as isError with the redacted stderr and the exit status, so a
11
+ * failed remote command cannot be mistaken for an empty success.
12
+ */
13
+ export function commandOutput(result) {
14
+ // ssh2 reports exitCode null when the process died from a signal.
15
+ const failed = result.exitCode !== 0 || Boolean(result.signal);
16
+ // entropyScan matches the SFTP and session-output paths. Without it a plain
17
+ // `env` dump hands high-entropy secrets straight to the model, since the
18
+ // regex layer only knows a fixed set of token shapes.
19
+ const stdout = redactText(result.stdout, { entropyScan: true });
20
+ const stderr = redactText(result.stderr, { entropyScan: true });
21
+ const parts = [];
22
+ if (stdout)
23
+ parts.push(stdout);
24
+ if (stderr)
25
+ parts.push(`[stderr]\n${stderr}`);
26
+ if (failed) {
27
+ parts.push(result.signal ? `[killed by SIG${result.signal}]` : `[exit ${result.exitCode}]`);
28
+ }
29
+ const content = [{ type: 'text', text: parts.join('\n\n') }];
30
+ return failed ? { content, isError: true } : { content };
31
+ }
@@ -0,0 +1,97 @@
1
+ import { z } from 'zod';
2
+ import { sanitizeSessionName } from '../guard/sanitizer.js';
3
+ import { redactText } from '../guard/redactor.js';
4
+ import { BackgroundSession } from '../ssh/session.js';
5
+ import { TOOL_DESCRIPTIONS as D } from './descriptions.js';
6
+ import { syntheticSuccess, textResult } from './results.js';
7
+ /** Connection and session discovery/lifecycle tools. */
8
+ export function registerSessionTools({ server, registry }, pipeline) {
9
+ const { runAudited, resolveConn } = pipeline;
10
+ // ─── list-connections ──────────────────────────────────────────────────
11
+ server.tool('list-connections', D["list-connections"], {}, { readOnlyHint: true }, async () => {
12
+ const connections = registry.listConnections();
13
+ const profiles = registry.listAllProfiles();
14
+ const lines = profiles.map((p) => {
15
+ const conn = connections.find((c) => c.profile === p.name);
16
+ const status = conn?.status || 'disconnected';
17
+ const sessions = conn?.sessionCount || 0;
18
+ return `${p.name}: ${p.user}@${p.host}:${p.port} [${status}] sessions=${sessions} role=${p.role}`;
19
+ });
20
+ return textResult(lines.join('\n'));
21
+ });
22
+ // ─── list-sessions ─────────────────────────────────────────────────────
23
+ server.tool('list-sessions', D["list-sessions"], { profile: z.string().optional().describe('Profile name (uses default if omitted)') }, { readOnlyHint: true }, async ({ profile }) => {
24
+ const conn = registry.get(profile);
25
+ if (!conn || conn.listSessions().length === 0) {
26
+ return textResult('No active sessions.');
27
+ }
28
+ const sessions = conn.listSessions().map((s) => {
29
+ const info = s.toInfo();
30
+ return `${info.name} [${info.type}] status=${info.status} idle=${Math.round((Date.now() - info.lastActivity.getTime()) / 1000)}s`;
31
+ });
32
+ return textResult(sessions.join('\n'));
33
+ });
34
+ // ─── open-session ──────────────────────────────────────────────────────
35
+ server.tool('open-session', D["open-session"], {
36
+ name: z.string().describe('Session name (alphanumeric, dash, underscore, max 64 chars)'),
37
+ type: z.enum(['interactive', 'background']).default('interactive').describe('Session type'),
38
+ command: z.string().optional().describe('Command for background sessions'),
39
+ profile: z.string().optional().describe('Profile name (uses default if omitted)'),
40
+ },
41
+ // No annotations object here on purpose. The SDK's overload resolution
42
+ // treats an EMPTY object as a Zod raw shape (isZodRawShape returns true for
43
+ // `{}`), so passing `{}` in the annotations slot made it consume the
44
+ // callback slot instead — leaving this tool registered with no handler and
45
+ // failing every call with "cb is not a function".
46
+ async ({ name, type, command, profile }, extra) => {
47
+ const cleanName = sanitizeSessionName(name);
48
+ const isBackground = type === 'background' && Boolean(command);
49
+ // A background session runs a caller-supplied command, so that command is
50
+ // what policy must evaluate. An interactive session runs no command yet,
51
+ // but opening a stateful shell is still a lifecycle event worth auditing
52
+ // — that branch used to produce no record at all.
53
+ return runAudited(isBackground ? command : `session:open ${type} ${cleanName}`, {
54
+ toolName: 'open-session',
55
+ failureClass: isBackground ? 'destructive' : 'safe',
56
+ profile,
57
+ session: cleanName,
58
+ extra,
59
+ synthetic: !isBackground,
60
+ }, async (rt) => {
61
+ await rt.conn.openSession({
62
+ name: cleanName,
63
+ type,
64
+ command: isBackground ? rt.command : command,
65
+ });
66
+ return {
67
+ audited: syntheticSuccess(rt.profileName),
68
+ output: textResult(`Session "${cleanName}" opened on ${rt.conn.profile.name} (${type}).`),
69
+ };
70
+ });
71
+ });
72
+ // ─── close-session ─────────────────────────────────────────────────────
73
+ server.tool('close-session', D["close-session"], {
74
+ name: z.string().describe('Session name to close'),
75
+ profile: z.string().optional().describe('Profile name'),
76
+ }, { destructiveHint: true }, async ({ name, profile }) => {
77
+ const conn = await resolveConn(profile);
78
+ await conn.closeSession(name);
79
+ return textResult(`Session "${name}" closed.`);
80
+ });
81
+ // ─── read-session-output ───────────────────────────────────────────────
82
+ server.tool('read-session-output', D["read-session-output"], {
83
+ name: z.string().describe('Background session name'),
84
+ lines: z.number().optional().default(50).describe('Number of recent lines to read'),
85
+ profile: z.string().optional().describe('Profile name'),
86
+ }, { readOnlyHint: true }, async ({ name, lines, profile }) => {
87
+ const conn = await resolveConn(profile);
88
+ const session = conn.getSession(name);
89
+ if (!session || session.type !== 'background') {
90
+ return textResult(`Background session "${name}" not found.`);
91
+ }
92
+ if (session instanceof BackgroundSession) {
93
+ return textResult(redactText(session.readOutput(lines), { entropyScan: true }));
94
+ }
95
+ return textResult(`Session "${name}" is not a background session.`);
96
+ });
97
+ }
@@ -0,0 +1,237 @@
1
+ import { createServer } from 'http';
2
+ import { randomUUID, timingSafeEqual } from 'crypto';
3
+ import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
4
+ import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js';
5
+ import { SERVER_VERSION } from '../version.js';
6
+ const MAX_BODY_SIZE = 1_048_576; // 1MB
7
+ /** Cap on concurrent MCP sessions, so unauthenticated-adjacent churn can't grow the map without bound. */
8
+ const MAX_SESSIONS = 64;
9
+ class RateLimiter {
10
+ tokens;
11
+ lastRefill;
12
+ maxTokens;
13
+ refillIntervalMs;
14
+ constructor(maxRequestsPerMinute) {
15
+ this.maxTokens = maxRequestsPerMinute;
16
+ this.tokens = maxRequestsPerMinute;
17
+ this.lastRefill = Date.now();
18
+ this.refillIntervalMs = 60_000;
19
+ }
20
+ tryConsume() {
21
+ const now = Date.now();
22
+ const elapsed = now - this.lastRefill;
23
+ const refilled = Math.floor((elapsed / this.refillIntervalMs) * this.maxTokens);
24
+ if (refilled > 0) {
25
+ this.tokens = Math.min(this.maxTokens, this.tokens + refilled);
26
+ this.lastRefill += Math.round((refilled / this.maxTokens) * this.refillIntervalMs);
27
+ }
28
+ if (this.tokens > 0) {
29
+ this.tokens--;
30
+ return { allowed: true, retryAfterMs: 0 };
31
+ }
32
+ const retryAfterMs = Math.ceil(this.refillIntervalMs / this.maxTokens);
33
+ return { allowed: false, retryAfterMs };
34
+ }
35
+ }
36
+ function jsonRpcError(res, status, code, message) {
37
+ res.writeHead(status, { 'Content-Type': 'application/json' });
38
+ res.end(JSON.stringify({ jsonrpc: '2.0', error: { code, message }, id: null }));
39
+ }
40
+ /**
41
+ * @param createMcpServer Builds a fresh McpServer per MCP session. An McpServer
42
+ * binds to exactly one transport, so a shared instance would let only the
43
+ * first client initialize — and would stay unusable after that client left.
44
+ */
45
+ export async function startHttpServer(createMcpServer, opts) {
46
+ const { port, host = '127.0.0.1', bearerToken, registry } = opts;
47
+ if (!bearerToken) {
48
+ throw new Error('HTTP transport requires --bearerToken. Example: --transport=http --bearerToken=secret\n' +
49
+ 'Without authentication, any network client can execute SSH commands on your hosts.');
50
+ }
51
+ const rateLimiter = opts.rateLimit && opts.rateLimit > 0
52
+ ? new RateLimiter(opts.rateLimit)
53
+ : null;
54
+ // DNS rebinding: a page the user visits can make their browser POST to a
55
+ // localhost server, and the bearer token does not help if the browser is
56
+ // tricked into attaching it. Validating the Host header is what stops it.
57
+ // GHSA-w48q-cv73-mx4w is exactly this, and the SDK leaves it to the caller.
58
+ const allowedHosts = opts.allowedHosts?.length
59
+ ? opts.allowedHosts
60
+ : [`${host}:${port}`, `localhost:${port}`, `127.0.0.1:${port}`, `[::1]:${port}`];
61
+ const transports = new Map();
62
+ /** Route to the session's transport, or start a new session on `initialize`. */
63
+ async function resolveTransport(req, res, parsedBody) {
64
+ const sessionId = req.headers['mcp-session-id'];
65
+ if (sessionId) {
66
+ const existing = transports.get(sessionId);
67
+ if (existing)
68
+ return existing;
69
+ jsonRpcError(res, 404, -32001, 'Session not found or expired. Re-initialize to obtain a new session.');
70
+ return null;
71
+ }
72
+ const body = Array.isArray(parsedBody) ? parsedBody : [parsedBody];
73
+ if (!body.some((m) => isInitializeRequest(m))) {
74
+ jsonRpcError(res, 400, -32000, 'Missing mcp-session-id header. Send an initialize request first.');
75
+ return null;
76
+ }
77
+ if (transports.size >= MAX_SESSIONS) {
78
+ jsonRpcError(res, 503, -32000, `Server is at its session limit (${MAX_SESSIONS}). Close an existing session and retry.`);
79
+ return null;
80
+ }
81
+ const transport = new StreamableHTTPServerTransport({
82
+ sessionIdGenerator: () => randomUUID(),
83
+ enableDnsRebindingProtection: true,
84
+ allowedHosts,
85
+ onsessioninitialized: (id) => { transports.set(id, transport); },
86
+ onsessionclosed: (id) => { transports.delete(id); },
87
+ });
88
+ // Covers transport teardown that isn't a DELETE (client disconnect, error).
89
+ transport.onclose = () => {
90
+ if (transport.sessionId)
91
+ transports.delete(transport.sessionId);
92
+ };
93
+ const mcp = await createMcpServer();
94
+ await mcp.connect(transport);
95
+ return transport;
96
+ }
97
+ const httpServer = createServer(async (req, res) => {
98
+ const url = new URL(req.url || '/', `http://${req.headers.host}`);
99
+ // Liveness probes are conventionally unauthenticated, and the README lists
100
+ // /health without an auth caveat. It exposes nothing beyond "process is up".
101
+ const isHealthProbe = req.method === 'GET' && url.pathname === '/health';
102
+ if (!isHealthProbe) {
103
+ const auth = req.headers.authorization || '';
104
+ const expected = `Bearer ${bearerToken}`;
105
+ const authBuf = Buffer.from(auth);
106
+ const expectedBuf = Buffer.from(expected);
107
+ const match = authBuf.length === expectedBuf.length &&
108
+ timingSafeEqual(authBuf, expectedBuf);
109
+ if (!match) {
110
+ // RFC 7235: a 401 must say how to authenticate. MCP clients also parse
111
+ // JSON-RPC envelopes on this route, so 401 speaks the same dialect as
112
+ // the 429 and 413 responses rather than a bare {error}.
113
+ res.writeHead(401, {
114
+ 'Content-Type': 'application/json',
115
+ 'WWW-Authenticate': 'Bearer realm="ssh-mcp"',
116
+ });
117
+ res.end(JSON.stringify({
118
+ jsonrpc: '2.0',
119
+ error: { code: -32001, message: 'Unauthorized' },
120
+ id: null,
121
+ }));
122
+ return;
123
+ }
124
+ }
125
+ if (rateLimiter && url.pathname === '/' && (req.method === 'POST' || req.method === 'GET' || req.method === 'DELETE')) {
126
+ const { allowed, retryAfterMs } = rateLimiter.tryConsume();
127
+ if (!allowed) {
128
+ const retryAfterSec = Math.ceil(retryAfterMs / 1000);
129
+ res.writeHead(429, {
130
+ 'Content-Type': 'application/json',
131
+ 'Retry-After': String(retryAfterSec),
132
+ });
133
+ res.end(JSON.stringify({
134
+ jsonrpc: '2.0',
135
+ error: {
136
+ code: -32604,
137
+ message: `Rate limit exceeded. Retry after ${retryAfterSec}s.`,
138
+ },
139
+ }));
140
+ return;
141
+ }
142
+ }
143
+ if (req.method === 'POST' && url.pathname === '/') {
144
+ let body = '';
145
+ let bodyTooLarge = false;
146
+ req.on('data', (chunk) => {
147
+ body += chunk;
148
+ if (body.length > MAX_BODY_SIZE) {
149
+ if (!bodyTooLarge) {
150
+ bodyTooLarge = true;
151
+ // Connection: close is load-bearing, not cosmetic. Without it a
152
+ // keep-alive client (Node's default agent since v19) returns this
153
+ // socket to its pool, and the destroy below then kills the pooled
154
+ // socket — so the client's *next* request fails with EPIPE.
155
+ res.writeHead(413, {
156
+ 'Content-Type': 'application/json',
157
+ 'Connection': 'close',
158
+ });
159
+ // Destroy only once the 413 has flushed; destroying immediately
160
+ // races the response and the client sees a bare connection reset
161
+ // instead of the status telling it what went wrong.
162
+ res.end(JSON.stringify({
163
+ jsonrpc: '2.0',
164
+ error: { code: -32600, message: 'Request body too large (max 1MB)' },
165
+ id: null,
166
+ }), () => req.destroy());
167
+ }
168
+ }
169
+ });
170
+ req.on('end', async () => {
171
+ if (bodyTooLarge)
172
+ return;
173
+ let parsed;
174
+ try {
175
+ parsed = JSON.parse(body);
176
+ }
177
+ catch {
178
+ jsonRpcError(res, 400, -32700, 'Parse error: invalid JSON');
179
+ return;
180
+ }
181
+ const transport = await resolveTransport(req, res, parsed);
182
+ if (!transport)
183
+ return;
184
+ await transport.handleRequest(req, res, parsed);
185
+ });
186
+ return;
187
+ }
188
+ if ((req.method === 'GET' || req.method === 'DELETE') && url.pathname === '/') {
189
+ const transport = await resolveTransport(req, res);
190
+ if (!transport)
191
+ return;
192
+ await transport.handleRequest(req, res);
193
+ return;
194
+ }
195
+ if (req.method === 'GET' && url.pathname === '/status') {
196
+ res.writeHead(200, { 'Content-Type': 'application/json' });
197
+ res.end(JSON.stringify({
198
+ status: 'running',
199
+ version: SERVER_VERSION,
200
+ connections: registry.listConnections(),
201
+ profiles: registry.listAllProfiles().map((p) => ({
202
+ name: p.name,
203
+ host: p.host,
204
+ user: p.user,
205
+ role: p.role,
206
+ readOnly: p.readOnly,
207
+ })),
208
+ }));
209
+ return;
210
+ }
211
+ if (req.method === 'GET' && url.pathname === '/health') {
212
+ res.writeHead(200, { 'Content-Type': 'application/json' });
213
+ res.end(JSON.stringify({ healthy: true }));
214
+ return;
215
+ }
216
+ res.writeHead(404, { 'Content-Type': 'application/json' });
217
+ res.end(JSON.stringify({ error: 'Not found' }));
218
+ });
219
+ // Previously listen() had no error handler, so EADDRINUSE surfaced as an
220
+ // unhandled 'error' event and a raw stack — and startHttpServer resolved
221
+ // immediately regardless, so the caller carried on as if the server was up.
222
+ await new Promise((resolve, reject) => {
223
+ httpServer.once('error', (err) => {
224
+ reject(err.code === 'EADDRINUSE'
225
+ ? new Error(`Cannot bind ${host}:${port} — address already in use.`)
226
+ : err);
227
+ });
228
+ httpServer.listen(port, host, () => {
229
+ console.error(`SSH MCP Server v2 (HTTP) listening on http://${host}:${port}`);
230
+ console.error('Endpoints: POST / (MCP), GET /status, GET /health');
231
+ if (rateLimiter) {
232
+ console.error(`Rate limit: ${opts.rateLimit} req/min`);
233
+ }
234
+ resolve();
235
+ });
236
+ });
237
+ }
package/build/types.js ADDED
@@ -0,0 +1,2 @@
1
+ // ─── Config Types ───────────────────────────────────────────────────────────
2
+ export {};
@@ -0,0 +1,9 @@
1
+ import { createRequire } from 'module';
2
+ const require = createRequire(import.meta.url);
3
+ /**
4
+ * Single source of truth for the version reported over MCP (`initialize`) and
5
+ * on `GET /status`. Read from package.json at runtime so a changesets release
6
+ * bump can't leave hardcoded literals behind, disagreeing with the installed
7
+ * package. Resolves to the repo root from both `src/` and `build/`.
8
+ */
9
+ export const SERVER_VERSION = require('../package.json').version;