@aaarc/handfree-ssh-mcp 1.0.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.
@@ -0,0 +1,25 @@
1
+ export const SERVER_CONFIG = {
2
+ name: "ssh-mcp-server",
3
+ version: "1.4.0",
4
+ };
5
+ export const SERVER_INSTRUCTIONS = `This server provides SSH access to servers loaded from OpenSSH config (~/.ssh/config by default) and optional YAML config/policy overlays.
6
+
7
+ Recommended workflow:
8
+ 1. Call list-servers first to discover which server names are available, enabled, and currently connected.
9
+ 2. Use show-whitelist before execute-command when you are unsure whether a command is allowed.
10
+ 3. Use execute-command for remote shell commands. Prefer a single command per call. Compound commands such as "cmd1 && cmd2" may be rejected by whitelist rules even if each subcommand is individually safe.
11
+ 4. Use stream=false for short commands that should finish quickly, such as pwd, ls, cat, head, tail, git status, or docker ps.
12
+ 5. Use stream=true for commands that may take longer or where incremental output is useful.
13
+ 6. Use upload and download for single-file SFTP transfers. Use transfer for recursive directory transfers or cross-server relay.
14
+ 7. Call help or help { tool: "<name>" } for detailed per-tool parameter docs and examples.
15
+
16
+ Server targeting:
17
+ - When only one server is enabled, connectionName can be omitted and the server is auto-selected.
18
+ - When multiple servers are enabled, connectionName is REQUIRED on execute-command, upload, download, show-whitelist, and transfer (upload/download mode). Omitting it returns an error listing the available names.
19
+
20
+ Behavior notes:
21
+ - A tool may automatically establish the SSH connection on first use.
22
+ - Command execution is restricted by the configured whitelist and blacklist. If a command is rejected, inspect the whitelist rather than retrying the same command repeatedly.
23
+ - Some commands return no output on success; this is normal.
24
+ - File transfer tools use SFTP and can fail if the remote path is missing or permissions are insufficient.
25
+ - OpenSSH/YAML config changes are hot-reloaded without restarting the server. Connection-field changes close the old client so the next tool call reconnects with fresh settings.`;
@@ -0,0 +1,376 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import os from "os";
4
+ import { Logger } from "../utils/logger.js";
5
+ const SUPPORTED_KEYS = new Set([
6
+ "hostname",
7
+ "user",
8
+ "port",
9
+ "identityfile",
10
+ "identityagent",
11
+ "identitiesonly",
12
+ ]);
13
+ export function getDefaultUserSshConfigPath() {
14
+ return path.join(os.homedir(), ".ssh", "config");
15
+ }
16
+ export function loadSshConfigFiles(configPaths = [getDefaultUserSshConfigPath()]) {
17
+ const directives = [];
18
+ const loadedFiles = new Set();
19
+ const visitedFiles = new Set();
20
+ for (const configPath of configPaths) {
21
+ parseConfigFile(resolveLocalPath(configPath, process.cwd()), ["*"], directives, loadedFiles, visitedFiles);
22
+ }
23
+ const rawHosts = buildRawHostConfigs(directives);
24
+ const configs = {};
25
+ for (const raw of rawHosts) {
26
+ configs[raw.alias] = toSshConfig(raw);
27
+ }
28
+ const configCount = Object.keys(configs).length;
29
+ if (configCount > 0) {
30
+ Logger.log(`Loaded ${configCount} host(s) from OpenSSH config: ${Array.from(loadedFiles).join(", ")}`, "info");
31
+ }
32
+ return {
33
+ configs,
34
+ files: Array.from(loadedFiles),
35
+ };
36
+ }
37
+ function parseConfigFile(filePath, inheritedPatterns, directives, loadedFiles, visitedFiles) {
38
+ if (!fs.existsSync(filePath)) {
39
+ return;
40
+ }
41
+ let realPath = filePath;
42
+ try {
43
+ realPath = fs.realpathSync(filePath);
44
+ }
45
+ catch {
46
+ realPath = path.resolve(filePath);
47
+ }
48
+ if (visitedFiles.has(realPath)) {
49
+ return;
50
+ }
51
+ visitedFiles.add(realPath);
52
+ loadedFiles.add(realPath);
53
+ const dir = path.dirname(realPath);
54
+ const lines = fs.readFileSync(realPath, "utf8").split(/\r?\n/);
55
+ let currentPatterns = inheritedPatterns;
56
+ for (let i = 0; i < lines.length; i++) {
57
+ const stripped = stripComment(lines[i]).trim();
58
+ if (!stripped)
59
+ continue;
60
+ const directiveArgs = parseDirectiveArgs(splitSshArgs(stripped));
61
+ if (!directiveArgs)
62
+ continue;
63
+ const key = directiveArgs.key.toLowerCase();
64
+ const rest = directiveArgs.values;
65
+ const value = rest.join(" ");
66
+ if (key === "host") {
67
+ currentPatterns = rest.length > 0 ? rest : [];
68
+ directives.push({
69
+ key,
70
+ value,
71
+ patterns: currentPatterns,
72
+ });
73
+ continue;
74
+ }
75
+ if (key === "match") {
76
+ currentPatterns = [];
77
+ continue;
78
+ }
79
+ if (key === "include") {
80
+ for (const includePattern of rest) {
81
+ for (const includeFile of expandLocalPattern(includePattern, dir)) {
82
+ parseConfigFile(includeFile, currentPatterns, directives, loadedFiles, visitedFiles);
83
+ }
84
+ }
85
+ continue;
86
+ }
87
+ directives.push({
88
+ key,
89
+ value,
90
+ patterns: currentPatterns,
91
+ });
92
+ }
93
+ }
94
+ function buildRawHostConfigs(directives) {
95
+ const aliases = new Map();
96
+ for (const directive of directives) {
97
+ if (directive.key !== "host")
98
+ continue;
99
+ for (const pattern of directive.patterns) {
100
+ if (pattern.startsWith("!"))
101
+ continue;
102
+ if (hasWildcard(pattern))
103
+ continue;
104
+ const lower = pattern.toLowerCase();
105
+ if (!aliases.has(lower)) {
106
+ aliases.set(lower, pattern);
107
+ }
108
+ }
109
+ }
110
+ const rawHosts = [];
111
+ for (const alias of aliases.values()) {
112
+ const options = new Map();
113
+ for (const directive of directives) {
114
+ if (directive.key === "host")
115
+ continue;
116
+ if (!SUPPORTED_KEYS.has(directive.key))
117
+ continue;
118
+ if (!hostPatternsMatch(directive.patterns, alias))
119
+ continue;
120
+ const optionKey = directive.key;
121
+ if (!options.has(optionKey)) {
122
+ options.set(optionKey, directive.value);
123
+ }
124
+ }
125
+ rawHosts.push({ alias, options });
126
+ }
127
+ return rawHosts;
128
+ }
129
+ function toSshConfig(raw) {
130
+ const rawHost = raw.options.get("hostname") ?? raw.alias;
131
+ const rawUser = raw.options.get("user") ?? os.userInfo().username;
132
+ const rawPort = raw.options.get("port") ?? "22";
133
+ const port = parsePort(raw.alias, rawPort);
134
+ const context = {
135
+ alias: raw.alias,
136
+ host: rawHost,
137
+ user: rawUser,
138
+ port: String(port),
139
+ };
140
+ const host = expandTokens(rawHost, context);
141
+ const username = expandTokens(rawUser, { ...context, host });
142
+ const identityFile = raw.options.get("identityfile");
143
+ const identityAgent = raw.options.get("identityagent");
144
+ const identitiesOnly = parseYesNo(raw.alias, "IdentitiesOnly", raw.options.get("identitiesonly"));
145
+ const resolvedContext = {
146
+ alias: raw.alias,
147
+ host,
148
+ user: username,
149
+ port: String(port),
150
+ };
151
+ const config = {
152
+ name: raw.alias,
153
+ host,
154
+ port,
155
+ username,
156
+ authOptional: true,
157
+ };
158
+ if (identitiesOnly !== undefined) {
159
+ config.identitiesOnly = identitiesOnly;
160
+ }
161
+ const identityFileDisabled = identityFile?.toLowerCase() === "none";
162
+ if (identityFile && !identityFileDisabled) {
163
+ config.privateKey = resolveLocalPath(expandTokens(identityFile, resolvedContext), process.cwd());
164
+ }
165
+ else if (!identityFileDisabled && !identitiesOnly) {
166
+ const defaultIdentity = findDefaultIdentityFile();
167
+ if (defaultIdentity) {
168
+ config.privateKey = defaultIdentity;
169
+ }
170
+ }
171
+ if (identityAgent?.toLowerCase() === "none") {
172
+ config.agent = false;
173
+ }
174
+ else if (identityAgent) {
175
+ config.agent = resolveLocalPath(expandTokens(identityAgent, resolvedContext), process.cwd());
176
+ }
177
+ return config;
178
+ }
179
+ function findDefaultIdentityFile() {
180
+ const sshDir = path.join(os.homedir(), ".ssh");
181
+ const candidates = [
182
+ "id_ed25519",
183
+ "id_ecdsa",
184
+ "id_ecdsa_sk",
185
+ "id_ed25519_sk",
186
+ "id_rsa",
187
+ "id_dsa",
188
+ ];
189
+ return candidates
190
+ .map((name) => path.join(sshDir, name))
191
+ .find((candidate) => fs.existsSync(candidate));
192
+ }
193
+ function parsePort(alias, rawPort) {
194
+ const port = Number.parseInt(rawPort, 10);
195
+ if (!Number.isInteger(port) || port <= 0 || port > 65535) {
196
+ throw new Error(`SSH config Host '${alias}': invalid Port '${rawPort}'`);
197
+ }
198
+ return port;
199
+ }
200
+ function parseYesNo(alias, optionName, rawValue) {
201
+ if (rawValue === undefined)
202
+ return undefined;
203
+ const normalized = rawValue.toLowerCase();
204
+ if (normalized === "yes")
205
+ return true;
206
+ if (normalized === "no")
207
+ return false;
208
+ throw new Error(`SSH config Host '${alias}': invalid ${optionName} '${rawValue}'`);
209
+ }
210
+ function hostPatternsMatch(patterns, alias) {
211
+ if (patterns.length === 0)
212
+ return false;
213
+ let positiveMatch = false;
214
+ for (const pattern of patterns) {
215
+ const negated = pattern.startsWith("!");
216
+ const body = negated ? pattern.slice(1) : pattern;
217
+ const matched = wildcardMatch(body, alias);
218
+ if (negated && matched) {
219
+ return false;
220
+ }
221
+ if (!negated && matched) {
222
+ positiveMatch = true;
223
+ }
224
+ }
225
+ return positiveMatch;
226
+ }
227
+ function wildcardMatch(pattern, value) {
228
+ return wildcardPatternRegex(pattern).test(value);
229
+ }
230
+ function hasWildcard(value) {
231
+ return /[*?]/.test(value);
232
+ }
233
+ function splitSshArgs(line) {
234
+ const out = [];
235
+ let current = "";
236
+ let quote = null;
237
+ for (let i = 0; i < line.length; i++) {
238
+ const ch = line[i];
239
+ if (quote) {
240
+ if (ch === quote) {
241
+ quote = null;
242
+ }
243
+ else if (ch === "\\" && i + 1 < line.length && line[i + 1] === quote) {
244
+ current += line[++i];
245
+ }
246
+ else {
247
+ current += ch;
248
+ }
249
+ continue;
250
+ }
251
+ if (ch === "'" || ch === "\"") {
252
+ quote = ch;
253
+ continue;
254
+ }
255
+ if (/\s/.test(ch)) {
256
+ if (current.length > 0) {
257
+ out.push(current);
258
+ current = "";
259
+ }
260
+ continue;
261
+ }
262
+ current += ch;
263
+ }
264
+ if (current.length > 0) {
265
+ out.push(current);
266
+ }
267
+ return out;
268
+ }
269
+ function parseDirectiveArgs(args) {
270
+ if (args.length === 0)
271
+ return null;
272
+ const firstEquals = args[0].indexOf("=");
273
+ if (firstEquals > 0) {
274
+ const key = args[0].slice(0, firstEquals);
275
+ const firstValue = args[0].slice(firstEquals + 1);
276
+ const values = firstValue ? [firstValue, ...args.slice(1)] : args.slice(1);
277
+ return values.length > 0 ? { key, values } : null;
278
+ }
279
+ if (args.length >= 2 && args[1] === "=") {
280
+ const values = args.slice(2);
281
+ return values.length > 0 ? { key: args[0], values } : null;
282
+ }
283
+ if (args.length >= 2 && args[1].startsWith("=")) {
284
+ const firstValue = args[1].slice(1);
285
+ const values = firstValue ? [firstValue, ...args.slice(2)] : args.slice(2);
286
+ return values.length > 0 ? { key: args[0], values } : null;
287
+ }
288
+ return args.length > 1 ? { key: args[0], values: args.slice(1) } : null;
289
+ }
290
+ function stripComment(line) {
291
+ let quote = null;
292
+ for (let i = 0; i < line.length; i++) {
293
+ const ch = line[i];
294
+ if (quote) {
295
+ if (ch === quote)
296
+ quote = null;
297
+ if (ch === "\\" && i + 1 < line.length)
298
+ i++;
299
+ continue;
300
+ }
301
+ if (ch === "'" || ch === "\"") {
302
+ quote = ch;
303
+ continue;
304
+ }
305
+ if (ch === "#") {
306
+ return line.slice(0, i);
307
+ }
308
+ }
309
+ return line;
310
+ }
311
+ function expandLocalPattern(patternText, baseDir) {
312
+ const resolved = resolveLocalPath(patternText, baseDir);
313
+ if (!hasWildcard(resolved)) {
314
+ return [resolved];
315
+ }
316
+ return expandGlobSegments(resolved);
317
+ }
318
+ function expandGlobSegments(patternText) {
319
+ const parsed = path.parse(patternText);
320
+ const relative = patternText.slice(parsed.root.length);
321
+ const parts = relative.split(/[\\/]+/).filter(Boolean);
322
+ let current = [parsed.root || process.cwd()];
323
+ for (const part of parts) {
324
+ const next = [];
325
+ const partHasGlob = hasWildcard(part);
326
+ const partRegex = partHasGlob ? wildcardPatternRegex(part) : null;
327
+ for (const dir of current) {
328
+ if (!partHasGlob) {
329
+ next.push(path.join(dir, part));
330
+ continue;
331
+ }
332
+ try {
333
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
334
+ if (partRegex?.test(entry.name)) {
335
+ next.push(path.join(dir, entry.name));
336
+ }
337
+ }
338
+ }
339
+ catch {
340
+ // Ignore unreadable glob roots, matching OpenSSH's permissive Include behavior.
341
+ }
342
+ }
343
+ current = next;
344
+ }
345
+ return current.filter((candidate) => fs.existsSync(candidate));
346
+ }
347
+ function resolveLocalPath(filePath, baseDir) {
348
+ const expanded = expandHome(filePath);
349
+ if (path.isAbsolute(expanded)) {
350
+ return path.normalize(expanded);
351
+ }
352
+ return path.resolve(baseDir, expanded);
353
+ }
354
+ function wildcardPatternRegex(pattern) {
355
+ const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&");
356
+ return new RegExp(`^${escaped.replace(/\*/g, ".*").replace(/\?/g, ".")}$`, "i");
357
+ }
358
+ function expandHome(filePath) {
359
+ if (filePath === "~")
360
+ return os.homedir();
361
+ if (filePath.startsWith("~/") || filePath.startsWith("~\\")) {
362
+ return path.join(os.homedir(), filePath.slice(2));
363
+ }
364
+ return filePath;
365
+ }
366
+ function expandTokens(value, context) {
367
+ return value
368
+ .replace(/%%/g, "\0PERCENT\0")
369
+ .replace(/%d/g, os.homedir())
370
+ .replace(/%h/g, context.host)
371
+ .replace(/%n/g, context.alias)
372
+ .replace(/%p/g, context.port)
373
+ .replace(/%r/g, context.user)
374
+ .replace(/%u/g, os.userInfo().username)
375
+ .replace(/\0PERCENT\0/g, "%");
376
+ }
@@ -0,0 +1,176 @@
1
+ import fs from "fs";
2
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
+ import { SSHConnectionManager } from "../services/ssh-connection-manager.js";
5
+ import { Logger } from "../utils/logger.js";
6
+ import { registerAllTools } from "../tools/index.js";
7
+ import { SERVER_CONFIG, SERVER_INSTRUCTIONS } from "../config/server.js";
8
+ import { getConfigPath, getEnabledServersArg, getLoadUserSshConfigFlag, getPreConnectFlag, getSshConfigPathsArg, loadConfigFromSources, } from "../config/config-loader.js";
9
+ /**
10
+ * MCP Server class
11
+ *
12
+ * handfree-ssh-mcp: Configure once via YAML, let the LLM handle the rest.
13
+ */
14
+ export class SshMcpServer {
15
+ server;
16
+ sshManager;
17
+ configWatchers = new Map();
18
+ configWatchersToRefresh = new Set();
19
+ configReloadTimer = null;
20
+ constructor() {
21
+ this.server = new McpServer(SERVER_CONFIG, {
22
+ instructions: SERVER_INSTRUCTIONS,
23
+ });
24
+ this.sshManager = SSHConnectionManager.getInstance();
25
+ }
26
+ /**
27
+ * Register tools
28
+ */
29
+ registerTools() {
30
+ registerAllTools(this.server);
31
+ }
32
+ /**
33
+ * Load configuration from OpenSSH config and optional YAML overlay.
34
+ *
35
+ * Optional: --config <path-to-yaml>
36
+ * Optional: --ssh-config <path-to-openssh-config>
37
+ * Optional: --enable-servers <server1,server2,...>
38
+ */
39
+ loadConfig() {
40
+ const args = process.argv.slice(2);
41
+ const parsedArgs = loadConfigFromSources(this.getConfigSourceOptions(args));
42
+ const enabledServers = getEnabledServersArg(args);
43
+ if (enabledServers && enabledServers.length > 0) {
44
+ // Validate that all enabled servers exist in config
45
+ for (const serverName of enabledServers) {
46
+ if (!parsedArgs.configs[serverName]) {
47
+ throw new Error(`Server '${serverName}' not found in config.\n\n` +
48
+ "Available servers: " + Object.keys(parsedArgs.configs).join(", "));
49
+ }
50
+ }
51
+ Logger.log(`Enabled servers: ${enabledServers.join(", ")}`, "info");
52
+ parsedArgs.enabledServers = enabledServers;
53
+ }
54
+ else {
55
+ Logger.log("No --enable-servers provided; all loaded servers are enabled", "info");
56
+ }
57
+ // CLI --pre-connect overrides YAML preConnect (CLI wins when present)
58
+ if (getPreConnectFlag(args)) {
59
+ parsedArgs.preConnect = true;
60
+ }
61
+ return parsedArgs;
62
+ }
63
+ getConfigSourceOptions(args) {
64
+ return {
65
+ yamlConfigPath: getConfigPath(args),
66
+ sshConfigPaths: getSshConfigPathsArg(args),
67
+ loadUserSshConfig: getLoadUserSshConfigFlag(args),
68
+ };
69
+ }
70
+ /**
71
+ * Watch loaded config files for changes and hot-reload connection settings
72
+ * plus policies without restarting the MCP process.
73
+ */
74
+ watchConfig(config) {
75
+ const args = process.argv.slice(2);
76
+ const enabledServers = config.enabledServers;
77
+ const scheduleReload = () => {
78
+ if (this.configReloadTimer)
79
+ clearTimeout(this.configReloadTimer);
80
+ this.configReloadTimer = setTimeout(reload, 500);
81
+ };
82
+ const reload = () => {
83
+ try {
84
+ Logger.log("Config file changed, hot-reloading SSH config...", "info");
85
+ const fresh = loadConfigFromSources(this.getConfigSourceOptions(args));
86
+ if (enabledServers) {
87
+ for (const serverName of enabledServers) {
88
+ if (!fresh.configs[serverName]) {
89
+ throw new Error(`Enabled server '${serverName}' no longer exists after reload. Available servers: ${Object.keys(fresh.configs).join(", ")}`);
90
+ }
91
+ }
92
+ fresh.enabledServers = enabledServers;
93
+ }
94
+ this.sshManager.replaceConfig(fresh.configs, fresh.enabledServers);
95
+ this.sshManager.setOutputLogRoot(fresh.outputLogDir);
96
+ this.reconcileConfigWatchers(fresh.watchPaths, scheduleReload);
97
+ }
98
+ catch (error) {
99
+ Logger.log(`Failed to hot-reload config: ${error.message}`, "error");
100
+ }
101
+ };
102
+ this.reconcileConfigWatchers(config.watchPaths, scheduleReload);
103
+ }
104
+ reconcileConfigWatchers(watchPaths, scheduleReload) {
105
+ if (watchPaths.length === 0) {
106
+ Logger.log("No config files to watch for live updates", "info");
107
+ }
108
+ const nextPaths = new Set(watchPaths);
109
+ for (const [configPath, watcher] of this.configWatchers) {
110
+ if (nextPaths.has(configPath) &&
111
+ !this.configWatchersToRefresh.has(configPath)) {
112
+ continue;
113
+ }
114
+ try {
115
+ watcher.close();
116
+ }
117
+ catch {
118
+ // Ignore close errors for already-dead watchers.
119
+ }
120
+ this.configWatchers.delete(configPath);
121
+ }
122
+ for (const configPath of nextPaths) {
123
+ if (this.configWatchers.has(configPath))
124
+ continue;
125
+ try {
126
+ const watcher = fs.watch(configPath, (eventType) => {
127
+ if (eventType !== "change" && eventType !== "rename")
128
+ return;
129
+ if (eventType === "rename") {
130
+ this.configWatchersToRefresh.add(configPath);
131
+ }
132
+ scheduleReload();
133
+ });
134
+ this.configWatchers.set(configPath, watcher);
135
+ this.configWatchersToRefresh.delete(configPath);
136
+ Logger.log(`Watching config file for live updates: ${configPath}`, "info");
137
+ }
138
+ catch (error) {
139
+ Logger.log(`Could not watch config file ${configPath} (hot-reload disabled for this file): ${error.message}`, "error");
140
+ }
141
+ }
142
+ }
143
+ /**
144
+ * Run the server
145
+ */
146
+ async run() {
147
+ // Initialize SSH configuration
148
+ const parsedArgs = this.loadConfig();
149
+ this.sshManager.setConfig(parsedArgs.configs, parsedArgs.enabledServers);
150
+ this.sshManager.setOutputLogRoot(parsedArgs.outputLogDir);
151
+ // Security warning
152
+ const allConfigs = Object.values(parsedArgs.configs);
153
+ if (allConfigs.some((c) => !c.commandWhitelist || c.commandWhitelist.length === 0)) {
154
+ Logger.log("WARNING: Running without a command whitelist is strongly discouraged. Please configure a whitelist to restrict the commands that can be executed.", "info");
155
+ }
156
+ // Pre-connect to enabled servers if flag is set
157
+ if (parsedArgs.preConnect) {
158
+ Logger.log("Pre-connecting to enabled SSH servers...", "info");
159
+ try {
160
+ await this.sshManager.connectAll();
161
+ Logger.log("Successfully pre-connected to enabled SSH servers", "info");
162
+ }
163
+ catch (error) {
164
+ Logger.log(`Warning: Some SSH connections failed during pre-connect: ${error.message}`, "error");
165
+ }
166
+ }
167
+ // Watch config files for live hot-reload
168
+ this.watchConfig(parsedArgs);
169
+ // Register tools
170
+ this.registerTools();
171
+ // Create transport instance and connect
172
+ const transport = new StdioServerTransport();
173
+ await this.server.connect(transport);
174
+ Logger.log("MCP server connection established");
175
+ }
176
+ }
package/build/index.js ADDED
@@ -0,0 +1,11 @@
1
+ #!/usr/bin/env node
2
+ import { SshMcpServer } from "./core/mcp-server.js";
3
+ import { Logger } from "./utils/logger.js";
4
+ /**
5
+ * Main program entry
6
+ */
7
+ async function main() {
8
+ const sshMcpServer = new SshMcpServer();
9
+ await sshMcpServer.run();
10
+ }
11
+ main().catch((error) => Logger.handleError(error, "【SSH MCP Server Error】", true));
@@ -0,0 +1 @@
1
+ export {};