@akms/mcp-ssh 0.0.1 → 0.0.2

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/lib/cli.cjs CHANGED
@@ -5,7 +5,7 @@ var stdio_js = require('@modelcontextprotocol/sdk/server/stdio.js');
5
5
  var os = require('os');
6
6
  var path2 = require('path');
7
7
  var crypto = require('crypto');
8
- var fs4 = require('fs');
8
+ var fs = require('fs');
9
9
  var string_decoder = require('string_decoder');
10
10
  var ssh2 = require('ssh2');
11
11
  var timers = require('timers');
@@ -16,16 +16,11 @@ function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
16
16
 
17
17
  var os__default = /*#__PURE__*/_interopDefault(os);
18
18
  var path2__default = /*#__PURE__*/_interopDefault(path2);
19
- var fs4__default = /*#__PURE__*/_interopDefault(fs4);
19
+ var fs__default = /*#__PURE__*/_interopDefault(fs);
20
20
  var ssh2__default = /*#__PURE__*/_interopDefault(ssh2);
21
21
 
22
22
  // src/_defs/defaults.ts
23
- var CONFIG_PATH_ENV = "SSH_MCP_CONFIG";
24
23
  var LOG_LEVEL_ENV = "SSH_MCP_LOG_LEVEL";
25
- var DEFAULT_CONFIG_FILENAMES = [
26
- ".akms-ssh.json",
27
- ".config/akms-ssh/config.json"
28
- ];
29
24
  var DEFAULT_SSH_PORT = 22;
30
25
  var DEFAULT_EXEC_TIMEOUT_MS = 6e4;
31
26
  var DEFAULT_CONNECT_TIMEOUT_MS = 2e4;
@@ -36,8 +31,8 @@ var SESSION_IDLE_TIMEOUT_MS = 15 * 60 * 1e3;
36
31
  var SESSION_SWEEP_INTERVAL_MS = 6e4;
37
32
  var CWD_MARKER = "__AKMS_SSH_CWD__";
38
33
  var MAX_DIRECTORY_ENTRIES = 1e3;
39
- var SERVER_NAME = "akms-ssh";
40
- var SERVER_VERSION = "0.0.1";
34
+ var SERVER_NAME = "akms-mcp-ssh";
35
+ var SERVER_VERSION = "0.0.2";
41
36
 
42
37
  // src/_defs/command-rules.ts
43
38
  var CRITICAL_PATH_PATTERN = new RegExp(
@@ -420,10 +415,6 @@ var PROTECTED_LOCAL_PATH_RULES = [
420
415
  pattern: /(^|\/)\.netrc$/i,
421
416
  reason: "netrc credentials"
422
417
  },
423
- {
424
- pattern: /(^|\/)\.?akms-ssh(\.json)?(\/|$)/i,
425
- reason: "this server's own host profile configuration"
426
- },
427
418
  {
428
419
  pattern: /(^|\/)\.claude(\.json)?(\/|$)/i,
429
420
  reason: "MCP client configuration"
@@ -448,7 +439,7 @@ var PROTECTED_LOCAL_PATH_RULES = [
448
439
 
449
440
  // src/_defs/env-vars.ts
450
441
  var SINGLE_HOST_ENV = {
451
- /** Presence of this variable selects single-host mode. */
442
+ /** Presence of this variable is what registers a host at all. */
452
443
  HOST: "SSH_HOST",
453
444
  PORT: "SSH_PORT",
454
445
  USER: "SSH_USER",
@@ -1052,14 +1043,14 @@ function inspectRemotePath(remotePath, policy, access) {
1052
1043
  function toRealPath(inputPath) {
1053
1044
  const _logger = $logger.child({ context: "toRealPath" });
1054
1045
  try {
1055
- return fs4__default.default.realpathSync.native(inputPath);
1046
+ return fs__default.default.realpathSync.native(inputPath);
1056
1047
  } catch (ex) {
1057
1048
  _logger.debug("path does not resolve, falling back to its parent directory", {
1058
1049
  reason: ex instanceof Error ? ex.name : "unknown"
1059
1050
  });
1060
1051
  }
1061
1052
  try {
1062
- const parentRealPath = fs4__default.default.realpathSync.native(path2__default.default.dirname(inputPath));
1053
+ const parentRealPath = fs__default.default.realpathSync.native(path2__default.default.dirname(inputPath));
1063
1054
  return path2__default.default.join(parentRealPath, path2__default.default.basename(inputPath));
1064
1055
  } catch (ex) {
1065
1056
  _logger.debug("parent directory does not resolve either, comparing lexically", {
@@ -1151,7 +1142,7 @@ function readPrivateKey(profile, keyPath) {
1151
1142
  return cached;
1152
1143
  }
1153
1144
  try {
1154
- const contents = fs4__default.default.readFileSync(keyPath);
1145
+ const contents = fs__default.default.readFileSync(keyPath);
1155
1146
  PRIVATE_KEY_CACHE.set(keyPath, contents);
1156
1147
  return contents;
1157
1148
  } catch (ex) {
@@ -1197,8 +1188,6 @@ function buildConnectConfig(profile, onHostKeySeen) {
1197
1188
  };
1198
1189
  if (profile.privateKeyPath != null) {
1199
1190
  connectConfig.privateKey = readPrivateKey(profile, profile.privateKeyPath);
1200
- } else if (profile.privateKey != null) {
1201
- connectConfig.privateKey = profile.privateKey;
1202
1191
  }
1203
1192
  if (profile.passphrase != null) {
1204
1193
  connectConfig.passphrase = profile.passphrase;
@@ -1215,7 +1204,7 @@ function buildRemoteCommand(command, cwd, trackCwd) {
1215
1204
  const lines = [];
1216
1205
  if (cwd != null && cwd !== "") {
1217
1206
  const quotedCwd = quoteShellArgument(cwd);
1218
- lines.push(`cd ${quotedCwd} 2>/dev/null || { echo 'akms-ssh: cannot enter the requested working directory' >&2; exit 1; }`);
1207
+ lines.push(`cd ${quotedCwd} 2>/dev/null || { echo 'akms-mcp-ssh: cannot enter the requested working directory' >&2; exit 1; }`);
1219
1208
  }
1220
1209
  lines.push(command);
1221
1210
  if (trackCwd == true) {
@@ -1928,7 +1917,7 @@ async function writeRemoteFile(connection, args) {
1928
1917
  }
1929
1918
  async function uploadFile(connection, localPath, remotePath) {
1930
1919
  requirePermittedPath(connection, remotePath, "write");
1931
- const localStats = fs4__default.default.statSync(localPath);
1920
+ const localStats = fs__default.default.statSync(localPath);
1932
1921
  if (localStats.isFile() == false) {
1933
1922
  throw new Error(`local path is not a file: ${localPath}`);
1934
1923
  }
@@ -1965,7 +1954,7 @@ async function downloadFile(connection, remotePath, localPath) {
1965
1954
  });
1966
1955
  });
1967
1956
  });
1968
- const localStats = fs4__default.default.statSync(localPath);
1957
+ const localStats = fs__default.default.statSync(localPath);
1969
1958
  const _logger = $logger.child({
1970
1959
  context: "downloadFile",
1971
1960
  profile: connection.profile.name,
@@ -1975,43 +1964,6 @@ async function downloadFile(connection, remotePath, localPath) {
1975
1964
  _logger.info("file downloaded", { bytes: localStats.size });
1976
1965
  return localStats.size;
1977
1966
  }
1978
- var POLICY_INPUT_SCHEMA = zod.z.strictObject({
1979
- readonly: zod.z.boolean().optional(),
1980
- allowSudo: zod.z.boolean().optional(),
1981
- execTimeoutMs: zod.z.number().int().positive().optional(),
1982
- connectTimeoutMs: zod.z.number().int().positive().optional(),
1983
- maxOutputCharacters: zod.z.number().int().positive().optional(),
1984
- maxReadFileBytes: zod.z.number().int().positive().optional(),
1985
- allowCommands: zod.z.array(zod.z.string()).optional(),
1986
- denyPatterns: zod.z.array(zod.z.string()).optional(),
1987
- allowedPaths: zod.z.array(zod.z.string()).optional()
1988
- });
1989
- var HOST_INPUT_SCHEMA = POLICY_INPUT_SCHEMA.extend({
1990
- // Kept strict by `.extend()` on a strict base — verified by the config tests.
1991
- host: zod.z.string().min(1),
1992
- port: zod.z.number().int().min(1).max(65535).optional(),
1993
- username: zod.z.string().min(1),
1994
- description: zod.z.string().optional(),
1995
- privateKeyPath: zod.z.string().optional(),
1996
- privateKey: zod.z.string().optional(),
1997
- passphrase: zod.z.string().optional(),
1998
- /** Name of an env var holding the passphrase, preferred over inlining it. */
1999
- passphraseEnv: zod.z.string().optional(),
2000
- password: zod.z.string().optional(),
2001
- /** Name of an env var holding the password, preferred over inlining it. */
2002
- passwordEnv: zod.z.string().optional(),
2003
- /** Pinned server host key, e.g. "SHA256:abc123…". */
2004
- hostKeyFingerprint: zod.z.string().optional(),
2005
- defaultCwd: zod.z.string().optional()
2006
- });
2007
- var CONFIG_INPUT_SCHEMA = zod.z.strictObject({
2008
- /** Ignored at runtime; present so editors can attach a JSON schema. */
2009
- $schema: zod.z.string().optional(),
2010
- defaults: POLICY_INPUT_SCHEMA.optional(),
2011
- hosts: zod.z.record(zod.z.string(), HOST_INPUT_SCHEMA)
2012
- });
2013
-
2014
- // src/modules/config/env-host.ts
2015
1967
  function readEnv(name) {
2016
1968
  const value = process.env[name];
2017
1969
  if (value == null) {
@@ -2059,220 +2011,84 @@ function readListEnv(name) {
2059
2011
  }
2060
2012
  return items;
2061
2013
  }
2062
- function buildConfigFromEnv() {
2063
- const host = readEnv(SINGLE_HOST_ENV.HOST);
2064
- if (host == null) {
2065
- return null;
2066
- }
2067
- const username = readEnv(SINGLE_HOST_ENV.USER);
2068
- if (username == null) {
2069
- throw new Error(`${SINGLE_HOST_ENV.HOST} is set, so ${SINGLE_HOST_ENV.USER} is required`);
2070
- }
2071
- const hostInput = {
2072
- host,
2073
- username,
2074
- port: readIntegerEnv(SINGLE_HOST_ENV.PORT),
2075
- description: readEnv(SINGLE_HOST_ENV.DESCRIPTION),
2076
- privateKeyPath: readEnv(SINGLE_HOST_ENV.KEY),
2077
- passphrase: readEnv(SINGLE_HOST_ENV.PASSPHRASE),
2078
- password: readEnv(SINGLE_HOST_ENV.PASSWORD),
2079
- hostKeyFingerprint: readEnv(SINGLE_HOST_ENV.HOST_KEY),
2080
- defaultCwd: readEnv(SINGLE_HOST_ENV.CWD),
2081
- readonly: readBooleanEnv(SINGLE_HOST_ENV.READONLY),
2082
- allowSudo: readBooleanEnv(SINGLE_HOST_ENV.ALLOW_SUDO),
2083
- allowCommands: readListEnv(SINGLE_HOST_ENV.ALLOW_COMMANDS),
2084
- denyPatterns: readListEnv(SINGLE_HOST_ENV.DENY_PATTERNS),
2085
- allowedPaths: readListEnv(SINGLE_HOST_ENV.ALLOWED_PATHS),
2086
- execTimeoutMs: readIntegerEnv(SINGLE_HOST_ENV.EXEC_TIMEOUT_MS),
2087
- connectTimeoutMs: readIntegerEnv(SINGLE_HOST_ENV.CONNECT_TIMEOUT_MS),
2088
- maxOutputCharacters: readIntegerEnv(SINGLE_HOST_ENV.MAX_OUTPUT_CHARACTERS),
2089
- maxReadFileBytes: readIntegerEnv(SINGLE_HOST_ENV.MAX_READ_FILE_BYTES)
2090
- };
2091
- const name = readEnv(SINGLE_HOST_ENV.NAME) ?? host;
2092
- const _logger = $logger.child({ context: "buildConfigFromEnv", profile: name });
2093
- _logger.debug("single-host config assembled from environment variables");
2094
- return { hosts: { [name]: hostInput } };
2095
- }
2096
-
2097
- // src/modules/config/load-config.ts
2098
- function resolveConfigSource(explicitPath) {
2099
- if (explicitPath != null && explicitPath.trim() !== "") {
2100
- return { kind: "file", path: expandHomePath(explicitPath) };
2101
- }
2102
- const fromSingleHostEnv = buildConfigFromEnv();
2103
- if (fromSingleHostEnv != null) {
2104
- return { kind: "env", config: fromSingleHostEnv };
2105
- }
2106
- const fromEnv = process.env[CONFIG_PATH_ENV];
2107
- if (fromEnv != null && fromEnv.trim() !== "") {
2108
- const trimmedValue = fromEnv.trim();
2109
- if (trimmedValue.startsWith("{") == true) {
2110
- return { kind: "inline", json: trimmedValue };
2111
- }
2112
- return { kind: "file", path: expandHomePath(trimmedValue) };
2113
- }
2114
- for (const filename of DEFAULT_CONFIG_FILENAMES) {
2115
- const candidate = path2__default.default.join(os__default.default.homedir(), filename);
2116
- if (fs4__default.default.existsSync(candidate) == true) {
2117
- return { kind: "file", path: candidate };
2118
- }
2119
- }
2120
- return { kind: "none" };
2121
- }
2122
- function compileDenyPatterns(profileName, sources) {
2014
+ function compileDenyPatterns(sources) {
2123
2015
  return sources.map((source) => {
2124
2016
  try {
2125
2017
  return new RegExp(source, "i");
2126
2018
  } catch (ex) {
2127
- const _logger = $logger.child({ context: "compileDenyPatterns", profile: profileName });
2019
+ const _logger = $logger.child({ context: "compileDenyPatterns" });
2128
2020
  _logger.error(ex, "denyPatterns entry is not a valid regular expression");
2129
2021
  let detail = String(ex);
2130
2022
  if (ex instanceof Error) {
2131
2023
  detail = ex.message;
2132
2024
  }
2133
- throw new Error(`profile '${profileName}' has an invalid denyPatterns entry /${source}/: ${detail}`);
2025
+ throw new Error(`${SINGLE_HOST_ENV.DENY_PATTERNS} has an invalid entry /${source}/: ${detail}`);
2134
2026
  }
2135
2027
  });
2136
2028
  }
2137
- function resolvePolicy(profileName, defaults, override) {
2138
- const allowCommands = [...override.allowCommands ?? defaults?.allowCommands ?? []];
2139
- if (allowCommands.length > 0 && allowCommands.includes("pwd") == false) {
2140
- allowCommands.push("pwd");
2029
+ function loadHostProfile() {
2030
+ const host = readEnv(SINGLE_HOST_ENV.HOST);
2031
+ if (host == null) {
2032
+ const _startupLogger = $logger.child({ context: "loadHostProfile" });
2033
+ _startupLogger.warn("no host is registered; set the connection variables and restart", {
2034
+ env_vars: [SINGLE_HOST_ENV.HOST, SINGLE_HOST_ENV.USER]
2035
+ });
2036
+ return null;
2141
2037
  }
2142
- const denyPatternSources = override.denyPatterns ?? defaults?.denyPatterns ?? [];
2143
- const allowedPaths = override.allowedPaths ?? defaults?.allowedPaths ?? [];
2144
- const _logger = $logger.child({ context: "resolvePolicy", profile: profileName });
2145
- if (override.allowCommands?.length === 0 && (defaults?.allowCommands?.length ?? 0) > 0) {
2146
- _logger.warn("host sets allowCommands to an empty list, which removes the restriction from defaults");
2038
+ const username = readEnv(SINGLE_HOST_ENV.USER);
2039
+ if (username == null) {
2040
+ throw new Error(`${SINGLE_HOST_ENV.HOST} is set, so ${SINGLE_HOST_ENV.USER} is required`);
2147
2041
  }
2148
- if (override.allowedPaths?.length === 0 && (defaults?.allowedPaths?.length ?? 0) > 0) {
2149
- _logger.warn("host sets allowedPaths to an empty list, which removes the restriction from defaults");
2042
+ const name = readEnv(SINGLE_HOST_ENV.NAME) ?? host;
2043
+ const _logger = $logger.child({ context: "loadHostProfile", profile: name });
2044
+ const allowCommands = readListEnv(SINGLE_HOST_ENV.ALLOW_COMMANDS) ?? [];
2045
+ if (allowCommands.length > 0 && allowCommands.includes("pwd") == false) {
2046
+ allowCommands.push("pwd");
2150
2047
  }
2151
- return {
2152
- readonly: override.readonly ?? defaults?.readonly ?? false,
2153
- allowSudo: override.allowSudo ?? defaults?.allowSudo ?? true,
2154
- execTimeoutMs: override.execTimeoutMs ?? defaults?.execTimeoutMs ?? DEFAULT_EXEC_TIMEOUT_MS,
2155
- connectTimeoutMs: override.connectTimeoutMs ?? defaults?.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS,
2156
- maxOutputCharacters: override.maxOutputCharacters ?? defaults?.maxOutputCharacters ?? DEFAULT_MAX_OUTPUT_CHARACTERS,
2157
- maxReadFileBytes: override.maxReadFileBytes ?? defaults?.maxReadFileBytes ?? DEFAULT_MAX_READ_FILE_BYTES,
2048
+ const policy = {
2049
+ readonly: readBooleanEnv(SINGLE_HOST_ENV.READONLY) ?? false,
2050
+ allowSudo: readBooleanEnv(SINGLE_HOST_ENV.ALLOW_SUDO) ?? true,
2051
+ execTimeoutMs: readIntegerEnv(SINGLE_HOST_ENV.EXEC_TIMEOUT_MS) ?? DEFAULT_EXEC_TIMEOUT_MS,
2052
+ connectTimeoutMs: readIntegerEnv(SINGLE_HOST_ENV.CONNECT_TIMEOUT_MS) ?? DEFAULT_CONNECT_TIMEOUT_MS,
2053
+ maxOutputCharacters: readIntegerEnv(SINGLE_HOST_ENV.MAX_OUTPUT_CHARACTERS) ?? DEFAULT_MAX_OUTPUT_CHARACTERS,
2054
+ maxReadFileBytes: readIntegerEnv(SINGLE_HOST_ENV.MAX_READ_FILE_BYTES) ?? DEFAULT_MAX_READ_FILE_BYTES,
2158
2055
  allowCommands,
2159
- denyPatterns: compileDenyPatterns(profileName, denyPatternSources),
2160
- allowedPaths
2056
+ denyPatterns: compileDenyPatterns(readListEnv(SINGLE_HOST_ENV.DENY_PATTERNS) ?? []),
2057
+ allowedPaths: readListEnv(SINGLE_HOST_ENV.ALLOWED_PATHS) ?? []
2161
2058
  };
2162
- }
2163
- function resolveSecret(profileName, inlineValue, envVarName) {
2164
- if (envVarName == null) {
2165
- return inlineValue;
2166
- }
2167
- const fromEnv = process.env[envVarName];
2168
- if (fromEnv == null || fromEnv === "") {
2169
- const _logger = $logger.child({ context: "resolveSecret", profile: profileName, env_var: envVarName });
2170
- _logger.warn("env var referenced by the profile is empty; falling back to the inline value");
2171
- return inlineValue;
2172
- }
2173
- return fromEnv;
2174
- }
2175
- function toHostProfile(name, raw, defaults) {
2176
2059
  const profile = {
2177
2060
  name,
2178
- host: raw.host,
2179
- port: raw.port ?? DEFAULT_SSH_PORT,
2180
- username: raw.username,
2181
- description: raw.description,
2182
- privateKey: raw.privateKey,
2183
- passphrase: resolveSecret(name, raw.passphrase, raw.passphraseEnv),
2184
- password: resolveSecret(name, raw.password, raw.passwordEnv),
2185
- hostKeyFingerprint: raw.hostKeyFingerprint,
2186
- defaultCwd: raw.defaultCwd,
2187
- policy: resolvePolicy(name, defaults, raw)
2061
+ host,
2062
+ port: readIntegerEnv(SINGLE_HOST_ENV.PORT) ?? DEFAULT_SSH_PORT,
2063
+ username,
2064
+ description: readEnv(SINGLE_HOST_ENV.DESCRIPTION),
2065
+ passphrase: readEnv(SINGLE_HOST_ENV.PASSPHRASE),
2066
+ password: readEnv(SINGLE_HOST_ENV.PASSWORD),
2067
+ hostKeyFingerprint: readEnv(SINGLE_HOST_ENV.HOST_KEY),
2068
+ defaultCwd: readEnv(SINGLE_HOST_ENV.CWD),
2069
+ policy
2188
2070
  };
2189
- if (raw.privateKeyPath != null) {
2190
- const expandedKeyPath = expandHomePath(raw.privateKeyPath);
2071
+ const keyPath = readEnv(SINGLE_HOST_ENV.KEY);
2072
+ if (keyPath != null) {
2073
+ const expandedKeyPath = expandHomePath(keyPath);
2191
2074
  profile.privateKeyPath = expandedKeyPath;
2192
- if (fs4__default.default.existsSync(expandedKeyPath) == false) {
2193
- const _logger = $logger.child({ context: "toHostProfile", profile: name });
2194
- _logger.warn("privateKeyPath does not exist; connections for this profile will fail", {
2075
+ if (fs__default.default.existsSync(expandedKeyPath) == false) {
2076
+ _logger.warn(`${SINGLE_HOST_ENV.KEY} does not exist; connections will fail`, {
2195
2077
  private_key_path: expandedKeyPath
2196
2078
  });
2197
2079
  }
2198
2080
  }
2199
- return profile;
2200
- }
2201
- function warnOnLoosePermissions(configPath) {
2202
- if (process.platform === "win32") {
2203
- return;
2204
- }
2205
- const stats = fs4__default.default.statSync(configPath);
2206
- const permissionBits = stats.mode & 511;
2207
- if ((permissionBits & 63) === 0) {
2208
- return;
2209
- }
2210
- const _logger = $logger.child({ context: "warnOnLoosePermissions", config_path: configPath });
2211
- _logger.warn(`config file is readable by other users, run: chmod 600 ${configPath}`, {
2212
- mode: permissionBits.toString(8)
2213
- });
2214
- }
2215
- function loadConfig(explicitPath) {
2216
- let _logger = $logger.child({ context: "loadConfig" });
2217
- const source = resolveConfigSource(explicitPath);
2218
- if (source.kind === "none") {
2219
- _logger.warn("no config found; no host profiles are registered", {
2220
- searched: DEFAULT_CONFIG_FILENAMES,
2221
- env_vars: [SINGLE_HOST_ENV.HOST, CONFIG_PATH_ENV]
2222
- });
2223
- return { profiles: /* @__PURE__ */ new Map(), sourcePath: null, sourceLabel: "none" };
2224
- }
2225
- let parsedJson = null;
2226
- let sourcePath = null;
2227
- let sourceLabel = `inline ${CONFIG_PATH_ENV}`;
2228
- if (source.kind === "env") {
2229
- _logger = _logger.child({ config_source: "env" });
2230
- sourceLabel = `${SINGLE_HOST_ENV.HOST} environment variables`;
2231
- parsedJson = source.config;
2232
- } else {
2233
- let rawText = "";
2234
- if (source.kind === "inline") {
2235
- _logger = _logger.child({ config_source: "inline" });
2236
- rawText = source.json;
2237
- } else {
2238
- _logger = _logger.child({ config_path: source.path });
2239
- sourcePath = source.path;
2240
- sourceLabel = source.path;
2241
- if (fs4__default.default.existsSync(source.path) == false) {
2242
- _logger.error(null, "config file does not exist");
2243
- throw new Error(`ssh mcp config not found: ${source.path}`);
2244
- }
2245
- rawText = fs4__default.default.readFileSync(source.path, "utf8");
2246
- }
2247
- try {
2248
- parsedJson = JSON.parse(rawText);
2249
- } catch (ex) {
2250
- _logger.error(ex, "config is not valid json");
2251
- throw new Error(`ssh mcp config is not valid JSON (${sourceLabel})`);
2252
- }
2253
- }
2254
- const validated = CONFIG_INPUT_SCHEMA.safeParse(parsedJson);
2255
- if (validated.success == false) {
2256
- const issueSummary = validated.error.issues.map((issue) => `${issue.path.join(".")}: ${issue.message}`).join("; ");
2257
- _logger.error(validated.error, "config failed schema validation");
2258
- throw new Error(`ssh mcp config is invalid (${sourceLabel}): ${issueSummary}`);
2259
- }
2260
- if (sourcePath != null) {
2261
- warnOnLoosePermissions(sourcePath);
2262
- }
2263
- const profiles = /* @__PURE__ */ new Map();
2264
- for (const [name, hostInput] of Object.entries(validated.data.hosts)) {
2265
- profiles.set(name, toHostProfile(name, hostInput, validated.data.defaults));
2266
- }
2267
- _logger.info("config loaded", {
2268
- profile_count: profiles.size,
2269
- profiles: Array.from(profiles.keys())
2081
+ _logger.info("host profile loaded", {
2082
+ host,
2083
+ port: profile.port,
2084
+ username,
2085
+ readonly: policy.readonly
2270
2086
  });
2271
- return { profiles, sourcePath, sourceLabel };
2087
+ return profile;
2272
2088
  }
2273
2089
  function toHostSummary(profile) {
2274
2090
  let authMethod = "agent";
2275
- if (profile.privateKeyPath != null || profile.privateKey != null) {
2091
+ if (profile.privateKeyPath != null) {
2276
2092
  authMethod = "privateKey";
2277
2093
  } else if (profile.password != null) {
2278
2094
  authMethod = "password";
@@ -2298,44 +2114,42 @@ var ENTRY_TYPE_MARKERS = {
2298
2114
  symlink: "l",
2299
2115
  other: "?"
2300
2116
  };
2301
- function formatHostList(config) {
2302
- if (config.profiles.size === 0) {
2117
+ function formatHostProfile(profile) {
2118
+ if (profile == null) {
2303
2119
  return [
2304
- "No SSH host profiles are registered.",
2120
+ "No SSH host is registered.",
2305
2121
  "",
2306
- `Put them in this server's MCP entry \u2014 ${CONFIG_PATH_ENV} takes the JSON itself:`,
2122
+ "Put the connection in this server's MCP entry \u2014 one server entry per host:",
2307
2123
  "",
2308
2124
  ` "env": {`,
2309
- ` "${CONFIG_PATH_ENV}": "{\\"hosts\\":{\\"my-server\\":{\\"host\\":\\"10.0.0.5\\",\\"username\\":\\"deploy\\",\\"privateKeyPath\\":\\"~/.ssh/id_ed25519\\"}}}"`,
2125
+ ` "${SINGLE_HOST_ENV.HOST}": "10.0.0.5",`,
2126
+ ` "${SINGLE_HOST_ENV.USER}": "deploy",`,
2127
+ ` "${SINGLE_HOST_ENV.KEY}": "~/.ssh/id_ed25519"`,
2310
2128
  " }",
2311
2129
  "",
2312
- `It also accepts a path to a JSON file, and ~/${DEFAULT_CONFIG_FILENAMES[0]} is picked up automatically.`,
2313
- "Restart this server after changing it."
2130
+ `${SINGLE_HOST_ENV.HOST} and ${SINGLE_HOST_ENV.USER} are the whole minimum.`,
2131
+ "Restart this server after changing it \u2014 environment variables are fixed for the life of the process."
2314
2132
  ].join("\n");
2315
2133
  }
2134
+ const summary = toHostSummary(profile);
2135
+ let accessLabel = "writable";
2136
+ if (summary.readonly == true) {
2137
+ accessLabel = "READ-ONLY";
2138
+ }
2139
+ let sudoLabel = "sudo=disabled";
2140
+ if (summary.allowSudo == true) {
2141
+ sudoLabel = "sudo=enabled";
2142
+ }
2316
2143
  const lines = [];
2317
- lines.push(`${config.profiles.size} SSH host profile(s) from ${config.sourceLabel}:`);
2318
- for (const profile of config.profiles.values()) {
2319
- const summary = toHostSummary(profile);
2320
- let accessLabel = "writable";
2321
- if (summary.readonly == true) {
2322
- accessLabel = "READ-ONLY";
2323
- }
2324
- let sudoLabel = "sudo=disabled";
2325
- if (summary.allowSudo == true) {
2326
- sudoLabel = "sudo=enabled";
2327
- }
2328
- lines.push("");
2329
- lines.push(`${summary.name} \u2192 ${summary.username}@${summary.host}:${summary.port} auth=${summary.authMethod} ${accessLabel} ${sudoLabel}`);
2330
- if (summary.description != null) {
2331
- lines.push(` ${summary.description}`);
2332
- }
2333
- if (summary.allowedPaths.length > 0) {
2334
- lines.push(` file tools limited to: ${summary.allowedPaths.join(", ")} (does not restrict ssh_exec)`);
2335
- }
2336
- if (summary.allowCommands.length > 0) {
2337
- lines.push(` commands limited to: ${summary.allowCommands.join(", ")}`);
2338
- }
2144
+ lines.push(`${summary.name} \u2192 ${summary.username}@${summary.host}:${summary.port} auth=${summary.authMethod} ${accessLabel} ${sudoLabel}`);
2145
+ if (summary.description != null) {
2146
+ lines.push(` ${summary.description}`);
2147
+ }
2148
+ if (summary.allowedPaths.length > 0) {
2149
+ lines.push(` file tools limited to: ${summary.allowedPaths.join(", ")} (does not restrict ssh_exec)`);
2150
+ }
2151
+ if (summary.allowCommands.length > 0) {
2152
+ lines.push(` commands limited to: ${summary.allowCommands.join(", ")}`);
2339
2153
  }
2340
2154
  return lines.join("\n");
2341
2155
  }
@@ -2447,40 +2261,16 @@ function toToolError(error, toolName, action) {
2447
2261
  _logger.error(error, `${toolName} failed`);
2448
2262
  return errorResult(`${toolName} failed: ${describeError(error)}`);
2449
2263
  }
2450
- function requireProfile(context, target) {
2451
- const profile = context.config.profiles.get(target);
2452
- if (profile != null) {
2453
- return profile;
2454
- }
2455
- const available = Array.from(context.config.profiles.keys());
2456
- if (available.length === 0) {
2457
- throw new Error(`no host profiles are registered \u2014 set ${SINGLE_HOST_ENV.HOST} / ${SINGLE_HOST_ENV.USER} in this server's env block (or ${CONFIG_PATH_ENV} for several hosts), then restart it`);
2458
- }
2459
- throw new Error(`unknown host profile '${target}' (registered: ${available.join(", ")})`);
2460
- }
2461
2264
  function resolveProfile(context, args) {
2462
- const hasTarget = args.target != null && args.target.trim() !== "";
2463
2265
  const hasSession = args.session != null && args.session.trim() !== "";
2464
- if (hasTarget == true && hasSession == true) {
2465
- throw new Error("pass either 'target' or 'session', not both \u2014 they can name different hosts with different policies");
2466
- }
2467
2266
  if (hasSession == true) {
2468
2267
  const session = context.sessions.require(args.session.trim());
2469
2268
  return { profile: session.profile, session };
2470
2269
  }
2471
- if (hasTarget == true) {
2472
- const profile = requireProfile(context, args.target.trim());
2473
- return { profile, session: null };
2474
- }
2475
- const onlyProfile = context.config.profiles.values().next().value;
2476
- if (context.config.profiles.size === 1 && onlyProfile != null) {
2477
- return { profile: onlyProfile, session: null };
2478
- }
2479
- if (context.config.profiles.size === 0) {
2480
- throw new Error(`no host profiles are registered \u2014 set ${SINGLE_HOST_ENV.HOST} / ${SINGLE_HOST_ENV.USER} in this server's env block, then restart it`);
2270
+ if (context.profile == null) {
2271
+ throw new Error(`no host is registered \u2014 set ${SINGLE_HOST_ENV.HOST} / ${SINGLE_HOST_ENV.USER} in this server's env block, then restart it`);
2481
2272
  }
2482
- const available = Array.from(context.config.profiles.keys());
2483
- throw new Error(`this server fronts several hosts, so 'target' is required (registered: ${available.join(", ")})`);
2273
+ return { profile: context.profile, session: null };
2484
2274
  }
2485
2275
  async function withHostConnection(profile, session, operation) {
2486
2276
  if (session != null) {
@@ -2501,12 +2291,6 @@ async function withHostConnection(profile, session, operation) {
2501
2291
  }
2502
2292
  }
2503
2293
  }
2504
- function protectedLocalPathsOf(context) {
2505
- if (context.config.sourcePath == null) {
2506
- return [];
2507
- }
2508
- return [context.config.sourcePath];
2509
- }
2510
2294
 
2511
2295
  // src/modules/tools/exec-tools.ts
2512
2296
  function registerExecTools(server, context) {
@@ -2515,14 +2299,13 @@ function registerExecTools(server, context) {
2515
2299
  {
2516
2300
  title: "Run a command over SSH",
2517
2301
  description: [
2518
- "Run a shell command on a registered SSH host and return stdout, stderr and the exit code.",
2519
- "With a single configured host, no target is needed. Pass 'session' to reuse an open session (which remembers its working directory), or 'target' to pick a host when several are registered \u2014 one or the other, never both.",
2302
+ "Run a shell command on the configured SSH host and return stdout, stderr and the exit code.",
2303
+ "Pass 'session' to reuse an open session, which remembers its working directory; omit it and a one-off connection is opened and closed around the call.",
2520
2304
  "A non-zero exit code is reported as normal output, not as a tool error \u2014 read the exit code and stderr to judge the outcome.",
2521
- "Almost everything is permitted: package installs, service restarts, sudo, interpreters. Only catastrophic operations are refused outright (wiping the filesystem root, formatting a disk, rebooting, flushing the firewall), and individual profiles may add their own limits."
2305
+ "Almost everything is permitted: package installs, service restarts, sudo, interpreters. Only catastrophic operations are refused outright (wiping the filesystem root, formatting a disk, rebooting, flushing the firewall), and the host may add its own limits."
2522
2306
  ].join(" "),
2523
2307
  inputSchema: {
2524
2308
  command: zod.z.string().min(1).describe("Shell command, exactly as it would be typed in a terminal. Chaining with ';', '&&', '||' and pipes is allowed; each part is screened separately."),
2525
- target: zod.z.string().optional().describe("Host profile alias. Omit it when this server fronts a single host, which is the usual setup. Opens a one-off connection that is closed afterwards."),
2526
2309
  session: zod.z.string().optional().describe("Session id from ssh_connect. Reuses that connection and keeps the working directory across calls."),
2527
2310
  cwd: zod.z.string().optional().describe("Absolute directory to run in, with no shell metacharacters. With a session, the session also moves there."),
2528
2311
  timeoutMs: zod.z.number().int().positive().optional().describe("Lowers the command timeout for this call; it cannot exceed the profile's own limit. The channel is closed when it elapses; the remote process may survive.")
@@ -2534,10 +2317,10 @@ function registerExecTools(server, context) {
2534
2317
  openWorldHint: true
2535
2318
  }
2536
2319
  },
2537
- async ({ command, target, session, cwd, timeoutMs }) => {
2320
+ async ({ command, session, cwd, timeoutMs }) => {
2538
2321
  let _logger = $logger.child({ context: "ssh_exec" });
2539
2322
  try {
2540
- const { profile, session: openSession } = resolveProfile(context, { target, session });
2323
+ const { profile, session: openSession } = resolveProfile(context, { session });
2541
2324
  _logger = _logger.child({ profile: profile.name, session_id: openSession?.sessionId });
2542
2325
  const requestedTimeoutMs = timeoutMs ?? profile.policy.execTimeoutMs;
2543
2326
  const effectiveTimeoutMs = Math.min(requestedTimeoutMs, profile.policy.execTimeoutMs);
@@ -2573,10 +2356,9 @@ function registerExecTools(server, context) {
2573
2356
  }
2574
2357
  );
2575
2358
  }
2576
- var TARGET_DESCRIPTION = "Host profile alias. Omit it when this server fronts a single host, which is the usual setup.";
2577
- var SESSION_DESCRIPTION = "Session id from ssh_connect, whose connection is reused. Pass this or 'target', not both.";
2578
- function requirePermittedLocalPath(context, localPath, access) {
2579
- const verdict = inspectLocalPath(localPath, access, protectedLocalPathsOf(context));
2359
+ var SESSION_DESCRIPTION = "Session id from ssh_connect, whose connection is reused. Omit it to open a one-off connection.";
2360
+ function requirePermittedLocalPath(localPath, access) {
2361
+ const verdict = inspectLocalPath(localPath, access);
2580
2362
  if (verdict.allowed == false) {
2581
2363
  throw new GuardRejectionError(verdict);
2582
2364
  }
@@ -2587,12 +2369,11 @@ function registerFileTools(server, context) {
2587
2369
  {
2588
2370
  title: "List a remote directory",
2589
2371
  description: [
2590
- "List a directory on a registered SSH host over SFTP, with type, permissions, size and mtime.",
2591
- "Cheaper and more structured than running 'ls -la' through ssh_exec, and it works on read-only profiles."
2372
+ "List a directory on the configured SSH host over SFTP, with type, permissions, size and mtime.",
2373
+ "Cheaper and more structured than running 'ls -la' through ssh_exec, and it works on a read-only host."
2592
2374
  ].join(" "),
2593
2375
  inputSchema: {
2594
- path: zod.z.string().min(1).describe("Absolute remote directory path. Profiles with allowedPaths set accept only paths inside them."),
2595
- target: zod.z.string().optional().describe(TARGET_DESCRIPTION),
2376
+ path: zod.z.string().min(1).describe("Absolute remote directory path. When SSH_ALLOWED_PATHS is set, only paths inside it are accepted."),
2596
2377
  session: zod.z.string().optional().describe(SESSION_DESCRIPTION)
2597
2378
  },
2598
2379
  annotations: {
@@ -2602,10 +2383,10 @@ function registerFileTools(server, context) {
2602
2383
  openWorldHint: true
2603
2384
  }
2604
2385
  },
2605
- async ({ path: remotePath, target, session }) => {
2386
+ async ({ path: remotePath, session }) => {
2606
2387
  const _logger = $logger.child({ context: "ssh_list_dir", remote_path: remotePath });
2607
2388
  try {
2608
- const { profile, session: openSession } = resolveProfile(context, { target, session });
2389
+ const { profile, session: openSession } = resolveProfile(context, { session });
2609
2390
  const listing = await withHostConnection(profile, openSession, async (connection) => {
2610
2391
  return await listRemoteDirectory(connection, remotePath);
2611
2392
  });
@@ -2632,9 +2413,8 @@ function registerFileTools(server, context) {
2632
2413
  ].join(" "),
2633
2414
  inputSchema: {
2634
2415
  path: zod.z.string().min(1).describe("Absolute remote file path."),
2635
- target: zod.z.string().optional().describe(TARGET_DESCRIPTION),
2636
2416
  session: zod.z.string().optional().describe(SESSION_DESCRIPTION),
2637
- maxBytes: zod.z.number().int().positive().optional().describe("Lowers the read ceiling for this call; it cannot exceed the profile's maxReadFileBytes.")
2417
+ maxBytes: zod.z.number().int().positive().optional().describe("Lowers the read ceiling for this call; it cannot exceed SSH_MAX_READ_BYTES.")
2638
2418
  },
2639
2419
  annotations: {
2640
2420
  readOnlyHint: true,
@@ -2643,10 +2423,10 @@ function registerFileTools(server, context) {
2643
2423
  openWorldHint: true
2644
2424
  }
2645
2425
  },
2646
- async ({ path: remotePath, target, session, maxBytes }) => {
2426
+ async ({ path: remotePath, session, maxBytes }) => {
2647
2427
  const _logger = $logger.child({ context: "ssh_read_file", remote_path: remotePath });
2648
2428
  try {
2649
- const { profile, session: openSession } = resolveProfile(context, { target, session });
2429
+ const { profile, session: openSession } = resolveProfile(context, { session });
2650
2430
  const requestedLimit = maxBytes ?? profile.policy.maxReadFileBytes;
2651
2431
  const readLimit = Math.min(requestedLimit, profile.policy.maxReadFileBytes);
2652
2432
  const file = await withHostConnection(profile, openSession, async (connection) => {
@@ -2678,14 +2458,13 @@ ${file.content}`);
2678
2458
  {
2679
2459
  title: "Write a remote file",
2680
2460
  description: [
2681
- "Write or append UTF-8 text to a file on a registered SSH host over SFTP.",
2461
+ "Write or append UTF-8 text to a file on the configured SSH host over SFTP.",
2682
2462
  "Use this instead of 'echo ... > file' through ssh_exec: no shell quoting to get wrong, and it works with multi-line content.",
2683
- "Refused on read-only profiles. The parent directory must already exist."
2463
+ "Refused on a read-only host. The parent directory must already exist."
2684
2464
  ].join(" "),
2685
2465
  inputSchema: {
2686
2466
  path: zod.z.string().min(1).describe("Absolute remote file path. Existing content is replaced unless 'append' is true."),
2687
2467
  content: zod.z.string().describe("UTF-8 text to write."),
2688
- target: zod.z.string().optional().describe(TARGET_DESCRIPTION),
2689
2468
  session: zod.z.string().optional().describe(SESSION_DESCRIPTION),
2690
2469
  append: zod.z.boolean().optional().describe("Append instead of replacing. Defaults to false.")
2691
2470
  },
@@ -2696,10 +2475,10 @@ ${file.content}`);
2696
2475
  openWorldHint: true
2697
2476
  }
2698
2477
  },
2699
- async ({ path: remotePath, content, target, session, append }) => {
2478
+ async ({ path: remotePath, content, session, append }) => {
2700
2479
  const _logger = $logger.child({ context: "ssh_write_file", remote_path: remotePath });
2701
2480
  try {
2702
- const { profile, session: openSession } = resolveProfile(context, { target, session });
2481
+ const { profile, session: openSession } = resolveProfile(context, { session });
2703
2482
  const shouldAppend = append ?? false;
2704
2483
  const writtenBytes = await withHostConnection(profile, openSession, async (connection) => {
2705
2484
  return await writeRemoteFile(connection, {
@@ -2724,14 +2503,13 @@ ${file.content}`);
2724
2503
  {
2725
2504
  title: "Upload a file over SFTP",
2726
2505
  description: [
2727
- "Upload a local file to a registered SSH host over SFTP.",
2728
- "Refused on read-only profiles, and for local paths holding credentials (SSH keys, cloud credentials, this server's own config).",
2506
+ "Upload a local file to the configured SSH host over SFTP.",
2507
+ "Refused on a read-only host, and for local paths holding credentials (SSH keys, cloud credentials).",
2729
2508
  "The remote parent directory must already exist."
2730
2509
  ].join(" "),
2731
2510
  inputSchema: {
2732
2511
  localPath: zod.z.string().min(1).describe("Local file path on the machine running this MCP server. '~' is expanded."),
2733
2512
  remotePath: zod.z.string().min(1).describe("Absolute destination path on the remote host, including the filename."),
2734
- target: zod.z.string().optional().describe(TARGET_DESCRIPTION),
2735
2513
  session: zod.z.string().optional().describe(SESSION_DESCRIPTION)
2736
2514
  },
2737
2515
  annotations: {
@@ -2741,13 +2519,13 @@ ${file.content}`);
2741
2519
  openWorldHint: true
2742
2520
  }
2743
2521
  },
2744
- async ({ localPath, remotePath, target, session }) => {
2522
+ async ({ localPath, remotePath, session }) => {
2745
2523
  const _logger = $logger.child({ context: "ssh_upload", remote_path: remotePath });
2746
2524
  try {
2747
- const { profile, session: openSession } = resolveProfile(context, { target, session });
2525
+ const { profile, session: openSession } = resolveProfile(context, { session });
2748
2526
  const resolvedLocalPath = expandHomePath(localPath);
2749
- requirePermittedLocalPath(context, resolvedLocalPath, "read");
2750
- if (fs4__default.default.existsSync(resolvedLocalPath) == false) {
2527
+ requirePermittedLocalPath(resolvedLocalPath, "read");
2528
+ if (fs__default.default.existsSync(resolvedLocalPath) == false) {
2751
2529
  return textResult(`local file not found: ${resolvedLocalPath}`);
2752
2530
  }
2753
2531
  const transferredBytes = await withHostConnection(profile, openSession, async (connection) => {
@@ -2765,14 +2543,13 @@ ${file.content}`);
2765
2543
  {
2766
2544
  title: "Download a file over SFTP",
2767
2545
  description: [
2768
- "Download a file from a registered SSH host to the machine running this MCP server.",
2546
+ "Download a file from the configured SSH host to the machine running this MCP server.",
2769
2547
  "Use this for binaries or files too large to read into the conversation; missing local directories are created.",
2770
- "The local destination is overwritten, and paths holding credentials or this server's own configuration are refused."
2548
+ "The local destination is overwritten, and paths holding credentials are refused."
2771
2549
  ].join(" "),
2772
2550
  inputSchema: {
2773
2551
  remotePath: zod.z.string().min(1).describe("Absolute source path on the remote host."),
2774
2552
  localPath: zod.z.string().min(1).describe("Local destination path, including the filename. '~' is expanded. Overwritten if it exists."),
2775
- target: zod.z.string().optional().describe(TARGET_DESCRIPTION),
2776
2553
  session: zod.z.string().optional().describe(SESSION_DESCRIPTION)
2777
2554
  },
2778
2555
  annotations: {
@@ -2782,13 +2559,13 @@ ${file.content}`);
2782
2559
  openWorldHint: true
2783
2560
  }
2784
2561
  },
2785
- async ({ remotePath, localPath, target, session }) => {
2562
+ async ({ remotePath, localPath, session }) => {
2786
2563
  const _logger = $logger.child({ context: "ssh_download", remote_path: remotePath });
2787
2564
  try {
2788
- const { profile, session: openSession } = resolveProfile(context, { target, session });
2565
+ const { profile, session: openSession } = resolveProfile(context, { session });
2789
2566
  const resolvedLocalPath = expandHomePath(localPath);
2790
- requirePermittedLocalPath(context, resolvedLocalPath, "write");
2791
- fs4__default.default.mkdirSync(path2__default.default.dirname(resolvedLocalPath), { recursive: true });
2567
+ requirePermittedLocalPath(resolvedLocalPath, "write");
2568
+ fs__default.default.mkdirSync(path2__default.default.dirname(resolvedLocalPath), { recursive: true });
2792
2569
  const transferredBytes = await withHostConnection(profile, openSession, async (connection) => {
2793
2570
  return await downloadFile(connection, remotePath, resolvedLocalPath);
2794
2571
  });
@@ -2806,11 +2583,11 @@ function registerHostTools(server, context) {
2806
2583
  server.registerTool(
2807
2584
  "ssh_list_hosts",
2808
2585
  {
2809
- title: "List SSH host profiles",
2586
+ title: "Show the SSH host",
2810
2587
  description: [
2811
- "List the SSH hosts this server may connect to, with each host's guard policy.",
2812
- "Call this first: every other tool takes a profile alias as its 'target', and no host outside this list can be reached.",
2813
- "Credentials are never returned \u2014 only which auth method the profile uses."
2588
+ "Show the SSH host this server connects to, with its guard policy.",
2589
+ "Call this first: it is the only reachable host, and its policy decides what the other tools will accept.",
2590
+ "Credentials are never returned \u2014 only which auth method the host uses."
2814
2591
  ].join(" "),
2815
2592
  inputSchema: {},
2816
2593
  annotations: {
@@ -2823,59 +2600,11 @@ function registerHostTools(server, context) {
2823
2600
  async () => {
2824
2601
  const _logger = $logger.child({ context: "ssh_list_hosts" });
2825
2602
  try {
2826
- const listing = formatHostList(context.config);
2827
- _logger.debug("host list returned", { profile_count: context.config.profiles.size });
2603
+ const listing = formatHostProfile(context.profile);
2604
+ _logger.debug("host profile returned", { profile: context.profile?.name });
2828
2605
  return textResult(listing);
2829
2606
  } catch (ex) {
2830
- return toToolError(ex, "ssh_list_hosts", "list hosts");
2831
- }
2832
- }
2833
- );
2834
- server.registerTool(
2835
- "ssh_reload_config",
2836
- {
2837
- title: "Reload the host configuration",
2838
- description: [
2839
- "Re-read the host configuration and apply it, without restarting the MCP server.",
2840
- "Use after the operator edits the config file \u2014 adding a host, tightening a policy.",
2841
- "Every open session is closed first, so the new policy is in force everywhere rather than leaving old sessions on the old rules.",
2842
- "Configuration supplied through environment variables is fixed for the life of the process; changing that still needs a restart."
2843
- ].join(" "),
2844
- inputSchema: {},
2845
- annotations: {
2846
- readOnlyHint: false,
2847
- destructiveHint: false,
2848
- idempotentHint: true,
2849
- openWorldHint: false
2850
- }
2851
- },
2852
- async () => {
2853
- const _logger = $logger.child({ context: "ssh_reload_config" });
2854
- try {
2855
- const previousLabel = context.config.sourceLabel;
2856
- const closedSessions = context.sessions.list().length;
2857
- context.sessions.closeAll();
2858
- const reloaded = context.reloadConfig();
2859
- const lines = [
2860
- `Reloaded ${reloaded.profiles.size} host profile(s) from ${reloaded.sourceLabel}.`
2861
- ];
2862
- if (reloaded.profiles.size > 0) {
2863
- lines.push(`Hosts: ${Array.from(reloaded.profiles.keys()).join(", ")}`);
2864
- }
2865
- if (closedSessions > 0) {
2866
- lines.push(`Closed ${closedSessions} open session(s); reopen with ssh_connect if needed.`);
2867
- }
2868
- if (reloaded.sourcePath == null) {
2869
- lines.push("This config comes from environment variables, which cannot change while the server runs \u2014 restart the MCP server to apply edits to them.");
2870
- }
2871
- _logger.info("config reloaded", {
2872
- profile_count: reloaded.profiles.size,
2873
- previous_source: previousLabel,
2874
- config_source: reloaded.sourceLabel
2875
- });
2876
- return textResult(lines.join("\n"));
2877
- } catch (ex) {
2878
- return toToolError(ex, "ssh_reload_config", "reload the configuration");
2607
+ return toToolError(ex, "ssh_list_hosts", "show the host");
2879
2608
  }
2880
2609
  }
2881
2610
  );
@@ -2886,13 +2615,12 @@ function registerSessionTools(server, context) {
2886
2615
  {
2887
2616
  title: "Open an SSH session",
2888
2617
  description: [
2889
- "Open a reusable SSH session to a registered host and return its session id.",
2890
- "Prefer this over one-off 'target' calls when running several commands: it pays the handshake once and remembers the working directory, so a 'cd' in one ssh_exec still applies to the next.",
2618
+ "Open a reusable SSH session to the configured host and return its session id.",
2619
+ "Prefer this over one-off calls when running several commands: it pays the handshake once and remembers the working directory, so a 'cd' in one ssh_exec still applies to the next.",
2891
2620
  "Close it with ssh_disconnect when done; idle sessions are reaped automatically, and a session whose connection drops reports that rather than silently reconnecting."
2892
2621
  ].join(" "),
2893
2622
  inputSchema: {
2894
- target: zod.z.string().optional().describe("Host profile alias. Omit it when this server fronts a single host, which is the usual setup."),
2895
- cwd: zod.z.string().optional().describe("Absolute directory to start in, with no shell metacharacters. Defaults to the profile's defaultCwd, else the login shell's directory.")
2623
+ cwd: zod.z.string().optional().describe("Absolute directory to start in, with no shell metacharacters. Defaults to SSH_CWD, else the login shell's directory.")
2896
2624
  },
2897
2625
  annotations: {
2898
2626
  readOnlyHint: false,
@@ -2901,11 +2629,11 @@ function registerSessionTools(server, context) {
2901
2629
  openWorldHint: true
2902
2630
  }
2903
2631
  },
2904
- async ({ target, cwd }) => {
2905
- let _logger = $logger.child({ context: "ssh_connect", target });
2632
+ async ({ cwd }) => {
2633
+ let _logger = $logger.child({ context: "ssh_connect" });
2906
2634
  let openedSessionId = null;
2907
2635
  try {
2908
- const { profile } = resolveProfile(context, { target });
2636
+ const { profile } = resolveProfile(context, {});
2909
2637
  const session = await context.sessions.open(profile);
2910
2638
  openedSessionId = session.sessionId;
2911
2639
  _logger = _logger.child({ session_id: session.sessionId });
@@ -2930,7 +2658,7 @@ ${probe.stderr.trim()}`);
2930
2658
  `cwd: ${session.cwd}`
2931
2659
  ];
2932
2660
  if (profile.policy.readonly == true) {
2933
- lines.push("This profile is READ-ONLY: writes, redirections and uploads will be rejected.");
2661
+ lines.push("This host is READ-ONLY: writes, redirections and uploads will be rejected.");
2934
2662
  }
2935
2663
  lines.push(`Run commands with ssh_exec({ session: "${session.sessionId}", command: "..." }).`);
2936
2664
  _logger.info("session opened via tool", { cwd: session.cwd });
@@ -2939,7 +2667,7 @@ ${probe.stderr.trim()}`);
2939
2667
  if (openedSessionId != null) {
2940
2668
  context.sessions.close(openedSessionId);
2941
2669
  }
2942
- return toToolError(ex, "ssh_connect", `connect to ${target ?? "the configured host"}`);
2670
+ return toToolError(ex, "ssh_connect", "connect to the configured host");
2943
2671
  }
2944
2672
  }
2945
2673
  );
@@ -3007,31 +2735,30 @@ function registerAllTools(server, context) {
3007
2735
 
3008
2736
  // src/server.ts
3009
2737
  var SERVER_INSTRUCTIONS = [
3010
- "This server runs shell commands and transfers files on remote hosts over SSH.",
3011
- "Only hosts pre-registered in a local config file are reachable \u2014 arbitrary host/credential arguments are not accepted.",
2738
+ "This server runs shell commands and transfers files on one remote host over SSH.",
2739
+ "The host is fixed in this server's own environment \u2014 there is no host argument, and no other machine is reachable through it.",
3012
2740
  "",
3013
- "Usually one server entry fronts one host, so no 'target' argument is needed \u2014 omit it and the configured host is used. ssh_list_hosts shows what is registered and what it permits.",
2741
+ "ssh_list_hosts shows which host this is and what it permits. Call it before assuming anything about access.",
3014
2742
  "",
3015
2743
  "Workflow:",
3016
2744
  "1. One command: ssh_exec with just 'command'.",
3017
2745
  "2. Several commands in a row: ssh_connect once, then ssh_exec with 'session' (the working directory persists across calls), then ssh_disconnect.",
3018
2746
  "3. Files: ssh_list_dir, ssh_read_file, ssh_write_file, ssh_upload, ssh_download \u2014 prefer these over shell equivalents; no quoting to get wrong.",
3019
- "Pass 'target' only when ssh_list_hosts shows several hosts, and never together with 'session'.",
3020
2747
  "",
3021
2748
  "What is permitted: ordinary administration, without asking \u2014 package installs, service restarts, sudo, interpreters, config edits, deployments.",
3022
- "Refused on every profile, and only these: operations that destroy the machine or cut this connection \u2014 wiping the filesystem root or a system directory, deleting a file the host cannot boot without, formatting a disk, writing raw bytes to a block device, rebooting or powering off, flushing the firewall, removing every cron job.",
3023
- "Individual profiles may add their own limits (read-only, an allow-list, deny patterns, path restrictions). ssh_list_hosts shows which apply where.",
2749
+ "Refused always, and only these: operations that destroy the machine or cut this connection \u2014 wiping the filesystem root or a system directory, deleting a file the host cannot boot without, formatting a disk, writing raw bytes to a block device, rebooting or powering off, flushing the firewall, removing every cron job.",
2750
+ "The host may add its own limits (read-only, an allow-list, deny patterns, path restrictions). ssh_list_hosts shows which apply.",
3024
2751
  "",
3025
2752
  "Commands run with no terminal and with stdin closed, so anything that waits for input fails immediately instead of hanging: use non-interactive flags (apt-get -y, sudo -n), 'top -bn1' rather than 'top', and ssh_write_file rather than an editor.",
3026
2753
  "A job that may outlive its timeout should be started detached (nohup \u2026 &, tmux new -d) and polled through its log.",
3027
2754
  "",
3028
2755
  "A guard rejection is a policy decision, not a transient failure \u2014 do not retry the same command; report it and ask before working around it.",
3029
2756
  "A non-zero exit code is returned as normal output; read it and stderr to judge what happened.",
3030
- "You still carry the judgement the guards do not: these are real hosts, and an irreversible action deserves a check with the operator first."
2757
+ "You still carry the judgement the guards do not: this is a real host, and an irreversible action deserves a check with the operator first."
3031
2758
  ].join("\n");
3032
- function createSshMcpServer(options) {
2759
+ function createSshMcpServer() {
3033
2760
  const _logger = $logger.child({ context: "createSshMcpServer" });
3034
- const config = loadConfig(options?.configPath);
2761
+ const profile = loadHostProfile();
3035
2762
  const sessions = new SshSessionManager();
3036
2763
  sessions.startIdleSweeper();
3037
2764
  const server = new mcp_js.McpServer(
@@ -3044,28 +2771,15 @@ function createSshMcpServer(options) {
3044
2771
  }
3045
2772
  );
3046
2773
  const context = {
3047
- config,
3048
- sessions,
3049
- reloadConfig() {
3050
- const reloaded = loadConfig(options?.configPath);
3051
- context.config = reloaded;
3052
- return reloaded;
3053
- }
2774
+ profile,
2775
+ sessions
3054
2776
  };
3055
2777
  registerAllTools(server, context);
3056
- _logger.info("mcp server built", {
3057
- profile_count: config.profiles.size,
3058
- config_source: config.sourceLabel
3059
- });
2778
+ _logger.info("mcp server built", { profile: profile?.name ?? null });
3060
2779
  return {
3061
2780
  server,
3062
2781
  sessions,
3063
- get config() {
3064
- return context.config;
3065
- },
3066
- reloadConfig() {
3067
- return context.reloadConfig();
3068
- },
2782
+ profile,
3069
2783
  shutdown() {
3070
2784
  sessions.stopIdleSweeper();
3071
2785
  sessions.closeAll();
@@ -3076,8 +2790,7 @@ function createSshMcpServer(options) {
3076
2790
  // src/cli.ts
3077
2791
  function parseArguments(argv) {
3078
2792
  const parsed = { showHelp: false, showVersion: false, checkOnly: false };
3079
- for (let index = 0; index < argv.length; index++) {
3080
- const argument = argv[index];
2793
+ for (const argument of argv) {
3081
2794
  if (argument === "--help" || argument === "-h") {
3082
2795
  parsed.showHelp = true;
3083
2796
  continue;
@@ -3090,23 +2803,6 @@ function parseArguments(argv) {
3090
2803
  parsed.checkOnly = true;
3091
2804
  continue;
3092
2805
  }
3093
- if (argument === "--config" || argument === "-c") {
3094
- const value = argv[index + 1];
3095
- if (value == null || value.startsWith("-") == true) {
3096
- throw new Error(`${argument} requires a file path`);
3097
- }
3098
- parsed.configPath = value;
3099
- index++;
3100
- continue;
3101
- }
3102
- if (argument.startsWith("--config=") == true) {
3103
- const value = argument.slice("--config=".length);
3104
- if (value === "") {
3105
- throw new Error("--config= requires a file path");
3106
- }
3107
- parsed.configPath = value;
3108
- continue;
3109
- }
3110
2806
  }
3111
2807
  return parsed;
3112
2808
  }
@@ -3114,20 +2810,32 @@ var HELP_TEXT = [
3114
2810
  `${SERVER_NAME} ${SERVER_VERSION} \u2014 MCP server for SSH command execution and SFTP transfers`,
3115
2811
  "",
3116
2812
  "Usage:",
3117
- " akms-ssh [--config <path>]",
2813
+ " akms-mcp-ssh [--check]",
3118
2814
  "",
3119
2815
  "Options:",
3120
- " -c, --config <path> Host profile config file",
3121
- ` (default: ${CONFIG_PATH_ENV} env var, else ~/${DEFAULT_CONFIG_FILENAMES[0]})`,
3122
- " --check Connect to every profile, report the result, and exit.",
3123
- " Prints each host key fingerprint so it can be pinned.",
2816
+ " --check Connect to the configured host, report the result, and exit.",
2817
+ " Prints the host key fingerprint so it can be pinned.",
3124
2818
  " -v, --version Print the version and exit",
3125
2819
  " -h, --help Print this help and exit",
3126
2820
  "",
3127
- "Environment:",
3128
- ` ${CONFIG_PATH_ENV} Host profiles as JSON, or a path to a JSON file.`,
3129
- " A value starting with '{' is read as the config itself, so an",
3130
- " MCP client can carry the whole setup in its own env block.",
2821
+ "Environment \u2014 one server entry per host, set in the MCP client's env block:",
2822
+ ` ${SINGLE_HOST_ENV.HOST} Hostname or IP (required)`,
2823
+ ` ${SINGLE_HOST_ENV.USER} Login user (required)`,
2824
+ ` ${SINGLE_HOST_ENV.PORT} Default 22`,
2825
+ ` ${SINGLE_HOST_ENV.KEY} Path to a private key file, '~' expanded`,
2826
+ ` ${SINGLE_HOST_ENV.PASSPHRASE} Key passphrase`,
2827
+ ` ${SINGLE_HOST_ENV.PASSWORD} Password auth, when no key is used`,
2828
+ ` ${SINGLE_HOST_ENV.HOST_KEY} Pinned host key, 'SHA256:\u2026'; a mismatch aborts the connection`,
2829
+ ` ${SINGLE_HOST_ENV.NAME} Alias shown to the model; defaults to the hostname`,
2830
+ ` ${SINGLE_HOST_ENV.DESCRIPTION} Shown to the model \u2014 say what the host is for`,
2831
+ ` ${SINGLE_HOST_ENV.CWD} Directory new sessions start in`,
2832
+ ` ${SINGLE_HOST_ENV.READONLY} Reject write commands, redirections, uploads (default false)`,
2833
+ ` ${SINGLE_HOST_ENV.ALLOW_SUDO} Allow sudo / su / doas (default true)`,
2834
+ ` ${SINGLE_HOST_ENV.ALLOW_COMMANDS} Comma-separated allow-list of binaries`,
2835
+ ` ${SINGLE_HOST_ENV.DENY_PATTERNS} Comma-separated extra deny regexes`,
2836
+ ` ${SINGLE_HOST_ENV.ALLOWED_PATHS} Comma-separated path prefixes the file tools may touch`,
2837
+ ` ${SINGLE_HOST_ENV.EXEC_TIMEOUT_MS} / ${SINGLE_HOST_ENV.CONNECT_TIMEOUT_MS}`,
2838
+ ` ${SINGLE_HOST_ENV.MAX_OUTPUT_CHARACTERS} / ${SINGLE_HOST_ENV.MAX_READ_FILE_BYTES}`,
3131
2839
  ` ${LOG_LEVEL_ENV} silent | debug | info | warn | error (default: info, written to stderr)`,
3132
2840
  "",
3133
2841
  "The server speaks MCP over stdio; run it from an MCP client, not interactively."
@@ -3136,79 +2844,56 @@ function describeAuth(profile) {
3136
2844
  if (profile.privateKeyPath != null) {
3137
2845
  return `key ${profile.privateKeyPath}`;
3138
2846
  }
3139
- if (profile.privateKey != null) {
3140
- return "inline key";
3141
- }
3142
2847
  if (profile.password != null) {
3143
2848
  return "password";
3144
2849
  }
3145
2850
  return "ssh agent";
3146
2851
  }
3147
- async function runConnectionCheck(config) {
3148
- if (config.profiles.size === 0) {
3149
- process.stdout.write(`No host profiles are registered (config source: ${config.sourceLabel}).
2852
+ async function runConnectionCheck(profile) {
2853
+ if (profile == null) {
2854
+ process.stdout.write(`No SSH host is registered \u2014 set ${SINGLE_HOST_ENV.HOST} and ${SINGLE_HOST_ENV.USER}.
3150
2855
  `);
3151
2856
  return 1;
3152
2857
  }
3153
- process.stdout.write(`Checking ${config.profiles.size} profile(s) from ${config.sourceLabel}
2858
+ const endpoint = `${profile.username}@${profile.host}:${profile.port}`;
2859
+ const auth = describeAuth(profile);
2860
+ process.stdout.write(`Checking ${profile.name} \u2192 ${endpoint}
3154
2861
 
3155
2862
  `);
3156
- const profiles = Array.from(config.profiles.values());
3157
- const results = await Promise.all(profiles.map(async (profile) => {
3158
- const connection = new SshConnection(profile);
3159
- const startedAt = Date.now();
3160
- try {
3161
- await connection.connect();
3162
- return {
3163
- profile,
3164
- connected: true,
3165
- durationMs: Date.now() - startedAt,
3166
- fingerprint: connection.hostKeyFingerprint,
3167
- detail: ""
3168
- };
3169
- } catch (ex) {
3170
- let detail = String(ex);
3171
- if (ex instanceof Error) {
3172
- detail = ex.message;
3173
- }
3174
- return {
3175
- profile,
3176
- connected: false,
3177
- durationMs: Date.now() - startedAt,
3178
- fingerprint: connection.hostKeyFingerprint,
3179
- detail
3180
- };
3181
- } finally {
3182
- connection.disconnect();
2863
+ const connection = new SshConnection(profile);
2864
+ const startedAt = Date.now();
2865
+ let connected = false;
2866
+ let detail = "";
2867
+ try {
2868
+ await connection.connect();
2869
+ connected = true;
2870
+ } catch (ex) {
2871
+ detail = String(ex);
2872
+ if (ex instanceof Error) {
2873
+ detail = ex.message;
3183
2874
  }
3184
- }));
3185
- let connectedCount = 0;
3186
- for (const result of results) {
3187
- const endpoint = `${result.profile.username}@${result.profile.host}:${result.profile.port}`;
3188
- const auth = describeAuth(result.profile);
3189
- if (result.connected == true) {
3190
- connectedCount += 1;
3191
- process.stdout.write(` OK ${result.profile.name} \u2192 ${endpoint} (${auth}, ${result.durationMs}ms)
2875
+ } finally {
2876
+ connection.disconnect();
2877
+ }
2878
+ const durationMs = Date.now() - startedAt;
2879
+ if (connected == true) {
2880
+ process.stdout.write(` OK ${endpoint} (${auth}, ${durationMs}ms)
3192
2881
  `);
3193
- } else {
3194
- process.stdout.write(` FAILED ${result.profile.name} \u2192 ${endpoint} (${auth})
2882
+ } else {
2883
+ process.stdout.write(` FAILED ${endpoint} (${auth})
3195
2884
  `);
3196
- process.stdout.write(` ${result.detail}
2885
+ process.stdout.write(` ${detail}
3197
2886
  `);
2887
+ }
2888
+ if (connection.hostKeyFingerprint != null) {
2889
+ let pinNote = `not pinned \u2014 set ${SINGLE_HOST_ENV.HOST_KEY} to it to detect a changed key`;
2890
+ if (profile.hostKeyFingerprint != null) {
2891
+ pinNote = "pinned";
3198
2892
  }
3199
- if (result.fingerprint != null) {
3200
- let pinNote = "not pinned \u2014 add it as hostKeyFingerprint to detect a changed key";
3201
- if (result.profile.hostKeyFingerprint != null) {
3202
- pinNote = "pinned";
3203
- }
3204
- process.stdout.write(` host key ${result.fingerprint} (${pinNote})
2893
+ process.stdout.write(` host key ${connection.hostKeyFingerprint} (${pinNote})
3205
2894
  `);
3206
- }
3207
2895
  }
3208
- process.stdout.write(`
3209
- ${connectedCount} of ${results.length} reachable
3210
- `);
3211
- if (connectedCount === results.length) {
2896
+ if (connected == true) {
3212
2897
  return 0;
3213
2898
  }
3214
2899
  return 1;
@@ -3229,9 +2914,9 @@ async function main() {
3229
2914
  if (cliArguments.checkOnly == true && process.env[LOG_LEVEL_ENV] == null) {
3230
2915
  process.env[LOG_LEVEL_ENV] = "silent";
3231
2916
  }
3232
- const instance = createSshMcpServer({ configPath: cliArguments.configPath });
2917
+ const instance = createSshMcpServer();
3233
2918
  if (cliArguments.checkOnly == true) {
3234
- const exitCode = await runConnectionCheck(instance.config);
2919
+ const exitCode = await runConnectionCheck(instance.profile);
3235
2920
  instance.shutdown();
3236
2921
  process.exit(exitCode);
3237
2922
  }
@@ -3261,8 +2946,7 @@ async function main() {
3261
2946
  _logger.info("mcp server ready on stdio", {
3262
2947
  server: SERVER_NAME,
3263
2948
  version: SERVER_VERSION,
3264
- config_source: instance.config.sourceLabel,
3265
- profile_count: instance.config.profiles.size
2949
+ profile: instance.profile?.name ?? null
3266
2950
  });
3267
2951
  }
3268
2952
  main().catch((error) => {
@@ -3272,7 +2956,7 @@ main().catch((error) => {
3272
2956
  if (error instanceof Error) {
3273
2957
  detail = error.message;
3274
2958
  }
3275
- process.stderr.write(`akms-ssh: ${detail}
2959
+ process.stderr.write(`akms-mcp-ssh: ${detail}
3276
2960
  `);
3277
2961
  process.exit(1);
3278
2962
  });