@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.js CHANGED
@@ -3,7 +3,7 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
3
3
  import os from 'os';
4
4
  import path2 from 'path';
5
5
  import { createHash } from 'crypto';
6
- import fs4 from 'fs';
6
+ import fs from 'fs';
7
7
  import { StringDecoder } from 'string_decoder';
8
8
  import ssh2 from 'ssh2';
9
9
  import { setInterval, clearInterval } from 'timers';
@@ -11,12 +11,7 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
11
11
  import { z } from 'zod';
12
12
 
13
13
  // src/_defs/defaults.ts
14
- var CONFIG_PATH_ENV = "SSH_MCP_CONFIG";
15
14
  var LOG_LEVEL_ENV = "SSH_MCP_LOG_LEVEL";
16
- var DEFAULT_CONFIG_FILENAMES = [
17
- ".akms-ssh.json",
18
- ".config/akms-ssh/config.json"
19
- ];
20
15
  var DEFAULT_SSH_PORT = 22;
21
16
  var DEFAULT_EXEC_TIMEOUT_MS = 6e4;
22
17
  var DEFAULT_CONNECT_TIMEOUT_MS = 2e4;
@@ -27,8 +22,8 @@ var SESSION_IDLE_TIMEOUT_MS = 15 * 60 * 1e3;
27
22
  var SESSION_SWEEP_INTERVAL_MS = 6e4;
28
23
  var CWD_MARKER = "__AKMS_SSH_CWD__";
29
24
  var MAX_DIRECTORY_ENTRIES = 1e3;
30
- var SERVER_NAME = "akms-ssh";
31
- var SERVER_VERSION = "0.0.1";
25
+ var SERVER_NAME = "akms-mcp-ssh";
26
+ var SERVER_VERSION = "0.0.2";
32
27
 
33
28
  // src/_defs/command-rules.ts
34
29
  var CRITICAL_PATH_PATTERN = new RegExp(
@@ -411,10 +406,6 @@ var PROTECTED_LOCAL_PATH_RULES = [
411
406
  pattern: /(^|\/)\.netrc$/i,
412
407
  reason: "netrc credentials"
413
408
  },
414
- {
415
- pattern: /(^|\/)\.?akms-ssh(\.json)?(\/|$)/i,
416
- reason: "this server's own host profile configuration"
417
- },
418
409
  {
419
410
  pattern: /(^|\/)\.claude(\.json)?(\/|$)/i,
420
411
  reason: "MCP client configuration"
@@ -439,7 +430,7 @@ var PROTECTED_LOCAL_PATH_RULES = [
439
430
 
440
431
  // src/_defs/env-vars.ts
441
432
  var SINGLE_HOST_ENV = {
442
- /** Presence of this variable selects single-host mode. */
433
+ /** Presence of this variable is what registers a host at all. */
443
434
  HOST: "SSH_HOST",
444
435
  PORT: "SSH_PORT",
445
436
  USER: "SSH_USER",
@@ -1043,14 +1034,14 @@ function inspectRemotePath(remotePath, policy, access) {
1043
1034
  function toRealPath(inputPath) {
1044
1035
  const _logger = $logger.child({ context: "toRealPath" });
1045
1036
  try {
1046
- return fs4.realpathSync.native(inputPath);
1037
+ return fs.realpathSync.native(inputPath);
1047
1038
  } catch (ex) {
1048
1039
  _logger.debug("path does not resolve, falling back to its parent directory", {
1049
1040
  reason: ex instanceof Error ? ex.name : "unknown"
1050
1041
  });
1051
1042
  }
1052
1043
  try {
1053
- const parentRealPath = fs4.realpathSync.native(path2.dirname(inputPath));
1044
+ const parentRealPath = fs.realpathSync.native(path2.dirname(inputPath));
1054
1045
  return path2.join(parentRealPath, path2.basename(inputPath));
1055
1046
  } catch (ex) {
1056
1047
  _logger.debug("parent directory does not resolve either, comparing lexically", {
@@ -1142,7 +1133,7 @@ function readPrivateKey(profile, keyPath) {
1142
1133
  return cached;
1143
1134
  }
1144
1135
  try {
1145
- const contents = fs4.readFileSync(keyPath);
1136
+ const contents = fs.readFileSync(keyPath);
1146
1137
  PRIVATE_KEY_CACHE.set(keyPath, contents);
1147
1138
  return contents;
1148
1139
  } catch (ex) {
@@ -1188,8 +1179,6 @@ function buildConnectConfig(profile, onHostKeySeen) {
1188
1179
  };
1189
1180
  if (profile.privateKeyPath != null) {
1190
1181
  connectConfig.privateKey = readPrivateKey(profile, profile.privateKeyPath);
1191
- } else if (profile.privateKey != null) {
1192
- connectConfig.privateKey = profile.privateKey;
1193
1182
  }
1194
1183
  if (profile.passphrase != null) {
1195
1184
  connectConfig.passphrase = profile.passphrase;
@@ -1206,7 +1195,7 @@ function buildRemoteCommand(command, cwd, trackCwd) {
1206
1195
  const lines = [];
1207
1196
  if (cwd != null && cwd !== "") {
1208
1197
  const quotedCwd = quoteShellArgument(cwd);
1209
- lines.push(`cd ${quotedCwd} 2>/dev/null || { echo 'akms-ssh: cannot enter the requested working directory' >&2; exit 1; }`);
1198
+ lines.push(`cd ${quotedCwd} 2>/dev/null || { echo 'akms-mcp-ssh: cannot enter the requested working directory' >&2; exit 1; }`);
1210
1199
  }
1211
1200
  lines.push(command);
1212
1201
  if (trackCwd == true) {
@@ -1919,7 +1908,7 @@ async function writeRemoteFile(connection, args) {
1919
1908
  }
1920
1909
  async function uploadFile(connection, localPath, remotePath) {
1921
1910
  requirePermittedPath(connection, remotePath, "write");
1922
- const localStats = fs4.statSync(localPath);
1911
+ const localStats = fs.statSync(localPath);
1923
1912
  if (localStats.isFile() == false) {
1924
1913
  throw new Error(`local path is not a file: ${localPath}`);
1925
1914
  }
@@ -1956,7 +1945,7 @@ async function downloadFile(connection, remotePath, localPath) {
1956
1945
  });
1957
1946
  });
1958
1947
  });
1959
- const localStats = fs4.statSync(localPath);
1948
+ const localStats = fs.statSync(localPath);
1960
1949
  const _logger = $logger.child({
1961
1950
  context: "downloadFile",
1962
1951
  profile: connection.profile.name,
@@ -1966,43 +1955,6 @@ async function downloadFile(connection, remotePath, localPath) {
1966
1955
  _logger.info("file downloaded", { bytes: localStats.size });
1967
1956
  return localStats.size;
1968
1957
  }
1969
- var POLICY_INPUT_SCHEMA = z.strictObject({
1970
- readonly: z.boolean().optional(),
1971
- allowSudo: z.boolean().optional(),
1972
- execTimeoutMs: z.number().int().positive().optional(),
1973
- connectTimeoutMs: z.number().int().positive().optional(),
1974
- maxOutputCharacters: z.number().int().positive().optional(),
1975
- maxReadFileBytes: z.number().int().positive().optional(),
1976
- allowCommands: z.array(z.string()).optional(),
1977
- denyPatterns: z.array(z.string()).optional(),
1978
- allowedPaths: z.array(z.string()).optional()
1979
- });
1980
- var HOST_INPUT_SCHEMA = POLICY_INPUT_SCHEMA.extend({
1981
- // Kept strict by `.extend()` on a strict base — verified by the config tests.
1982
- host: z.string().min(1),
1983
- port: z.number().int().min(1).max(65535).optional(),
1984
- username: z.string().min(1),
1985
- description: z.string().optional(),
1986
- privateKeyPath: z.string().optional(),
1987
- privateKey: z.string().optional(),
1988
- passphrase: z.string().optional(),
1989
- /** Name of an env var holding the passphrase, preferred over inlining it. */
1990
- passphraseEnv: z.string().optional(),
1991
- password: z.string().optional(),
1992
- /** Name of an env var holding the password, preferred over inlining it. */
1993
- passwordEnv: z.string().optional(),
1994
- /** Pinned server host key, e.g. "SHA256:abc123…". */
1995
- hostKeyFingerprint: z.string().optional(),
1996
- defaultCwd: z.string().optional()
1997
- });
1998
- var CONFIG_INPUT_SCHEMA = z.strictObject({
1999
- /** Ignored at runtime; present so editors can attach a JSON schema. */
2000
- $schema: z.string().optional(),
2001
- defaults: POLICY_INPUT_SCHEMA.optional(),
2002
- hosts: z.record(z.string(), HOST_INPUT_SCHEMA)
2003
- });
2004
-
2005
- // src/modules/config/env-host.ts
2006
1958
  function readEnv(name) {
2007
1959
  const value = process.env[name];
2008
1960
  if (value == null) {
@@ -2050,220 +2002,84 @@ function readListEnv(name) {
2050
2002
  }
2051
2003
  return items;
2052
2004
  }
2053
- function buildConfigFromEnv() {
2054
- const host = readEnv(SINGLE_HOST_ENV.HOST);
2055
- if (host == null) {
2056
- return null;
2057
- }
2058
- const username = readEnv(SINGLE_HOST_ENV.USER);
2059
- if (username == null) {
2060
- throw new Error(`${SINGLE_HOST_ENV.HOST} is set, so ${SINGLE_HOST_ENV.USER} is required`);
2061
- }
2062
- const hostInput = {
2063
- host,
2064
- username,
2065
- port: readIntegerEnv(SINGLE_HOST_ENV.PORT),
2066
- description: readEnv(SINGLE_HOST_ENV.DESCRIPTION),
2067
- privateKeyPath: readEnv(SINGLE_HOST_ENV.KEY),
2068
- passphrase: readEnv(SINGLE_HOST_ENV.PASSPHRASE),
2069
- password: readEnv(SINGLE_HOST_ENV.PASSWORD),
2070
- hostKeyFingerprint: readEnv(SINGLE_HOST_ENV.HOST_KEY),
2071
- defaultCwd: readEnv(SINGLE_HOST_ENV.CWD),
2072
- readonly: readBooleanEnv(SINGLE_HOST_ENV.READONLY),
2073
- allowSudo: readBooleanEnv(SINGLE_HOST_ENV.ALLOW_SUDO),
2074
- allowCommands: readListEnv(SINGLE_HOST_ENV.ALLOW_COMMANDS),
2075
- denyPatterns: readListEnv(SINGLE_HOST_ENV.DENY_PATTERNS),
2076
- allowedPaths: readListEnv(SINGLE_HOST_ENV.ALLOWED_PATHS),
2077
- execTimeoutMs: readIntegerEnv(SINGLE_HOST_ENV.EXEC_TIMEOUT_MS),
2078
- connectTimeoutMs: readIntegerEnv(SINGLE_HOST_ENV.CONNECT_TIMEOUT_MS),
2079
- maxOutputCharacters: readIntegerEnv(SINGLE_HOST_ENV.MAX_OUTPUT_CHARACTERS),
2080
- maxReadFileBytes: readIntegerEnv(SINGLE_HOST_ENV.MAX_READ_FILE_BYTES)
2081
- };
2082
- const name = readEnv(SINGLE_HOST_ENV.NAME) ?? host;
2083
- const _logger = $logger.child({ context: "buildConfigFromEnv", profile: name });
2084
- _logger.debug("single-host config assembled from environment variables");
2085
- return { hosts: { [name]: hostInput } };
2086
- }
2087
-
2088
- // src/modules/config/load-config.ts
2089
- function resolveConfigSource(explicitPath) {
2090
- if (explicitPath != null && explicitPath.trim() !== "") {
2091
- return { kind: "file", path: expandHomePath(explicitPath) };
2092
- }
2093
- const fromSingleHostEnv = buildConfigFromEnv();
2094
- if (fromSingleHostEnv != null) {
2095
- return { kind: "env", config: fromSingleHostEnv };
2096
- }
2097
- const fromEnv = process.env[CONFIG_PATH_ENV];
2098
- if (fromEnv != null && fromEnv.trim() !== "") {
2099
- const trimmedValue = fromEnv.trim();
2100
- if (trimmedValue.startsWith("{") == true) {
2101
- return { kind: "inline", json: trimmedValue };
2102
- }
2103
- return { kind: "file", path: expandHomePath(trimmedValue) };
2104
- }
2105
- for (const filename of DEFAULT_CONFIG_FILENAMES) {
2106
- const candidate = path2.join(os.homedir(), filename);
2107
- if (fs4.existsSync(candidate) == true) {
2108
- return { kind: "file", path: candidate };
2109
- }
2110
- }
2111
- return { kind: "none" };
2112
- }
2113
- function compileDenyPatterns(profileName, sources) {
2005
+ function compileDenyPatterns(sources) {
2114
2006
  return sources.map((source) => {
2115
2007
  try {
2116
2008
  return new RegExp(source, "i");
2117
2009
  } catch (ex) {
2118
- const _logger = $logger.child({ context: "compileDenyPatterns", profile: profileName });
2010
+ const _logger = $logger.child({ context: "compileDenyPatterns" });
2119
2011
  _logger.error(ex, "denyPatterns entry is not a valid regular expression");
2120
2012
  let detail = String(ex);
2121
2013
  if (ex instanceof Error) {
2122
2014
  detail = ex.message;
2123
2015
  }
2124
- throw new Error(`profile '${profileName}' has an invalid denyPatterns entry /${source}/: ${detail}`);
2016
+ throw new Error(`${SINGLE_HOST_ENV.DENY_PATTERNS} has an invalid entry /${source}/: ${detail}`);
2125
2017
  }
2126
2018
  });
2127
2019
  }
2128
- function resolvePolicy(profileName, defaults, override) {
2129
- const allowCommands = [...override.allowCommands ?? defaults?.allowCommands ?? []];
2130
- if (allowCommands.length > 0 && allowCommands.includes("pwd") == false) {
2131
- allowCommands.push("pwd");
2020
+ function loadHostProfile() {
2021
+ const host = readEnv(SINGLE_HOST_ENV.HOST);
2022
+ if (host == null) {
2023
+ const _startupLogger = $logger.child({ context: "loadHostProfile" });
2024
+ _startupLogger.warn("no host is registered; set the connection variables and restart", {
2025
+ env_vars: [SINGLE_HOST_ENV.HOST, SINGLE_HOST_ENV.USER]
2026
+ });
2027
+ return null;
2132
2028
  }
2133
- const denyPatternSources = override.denyPatterns ?? defaults?.denyPatterns ?? [];
2134
- const allowedPaths = override.allowedPaths ?? defaults?.allowedPaths ?? [];
2135
- const _logger = $logger.child({ context: "resolvePolicy", profile: profileName });
2136
- if (override.allowCommands?.length === 0 && (defaults?.allowCommands?.length ?? 0) > 0) {
2137
- _logger.warn("host sets allowCommands to an empty list, which removes the restriction from defaults");
2029
+ const username = readEnv(SINGLE_HOST_ENV.USER);
2030
+ if (username == null) {
2031
+ throw new Error(`${SINGLE_HOST_ENV.HOST} is set, so ${SINGLE_HOST_ENV.USER} is required`);
2138
2032
  }
2139
- if (override.allowedPaths?.length === 0 && (defaults?.allowedPaths?.length ?? 0) > 0) {
2140
- _logger.warn("host sets allowedPaths to an empty list, which removes the restriction from defaults");
2033
+ const name = readEnv(SINGLE_HOST_ENV.NAME) ?? host;
2034
+ const _logger = $logger.child({ context: "loadHostProfile", profile: name });
2035
+ const allowCommands = readListEnv(SINGLE_HOST_ENV.ALLOW_COMMANDS) ?? [];
2036
+ if (allowCommands.length > 0 && allowCommands.includes("pwd") == false) {
2037
+ allowCommands.push("pwd");
2141
2038
  }
2142
- return {
2143
- readonly: override.readonly ?? defaults?.readonly ?? false,
2144
- allowSudo: override.allowSudo ?? defaults?.allowSudo ?? true,
2145
- execTimeoutMs: override.execTimeoutMs ?? defaults?.execTimeoutMs ?? DEFAULT_EXEC_TIMEOUT_MS,
2146
- connectTimeoutMs: override.connectTimeoutMs ?? defaults?.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS,
2147
- maxOutputCharacters: override.maxOutputCharacters ?? defaults?.maxOutputCharacters ?? DEFAULT_MAX_OUTPUT_CHARACTERS,
2148
- maxReadFileBytes: override.maxReadFileBytes ?? defaults?.maxReadFileBytes ?? DEFAULT_MAX_READ_FILE_BYTES,
2039
+ const policy = {
2040
+ readonly: readBooleanEnv(SINGLE_HOST_ENV.READONLY) ?? false,
2041
+ allowSudo: readBooleanEnv(SINGLE_HOST_ENV.ALLOW_SUDO) ?? true,
2042
+ execTimeoutMs: readIntegerEnv(SINGLE_HOST_ENV.EXEC_TIMEOUT_MS) ?? DEFAULT_EXEC_TIMEOUT_MS,
2043
+ connectTimeoutMs: readIntegerEnv(SINGLE_HOST_ENV.CONNECT_TIMEOUT_MS) ?? DEFAULT_CONNECT_TIMEOUT_MS,
2044
+ maxOutputCharacters: readIntegerEnv(SINGLE_HOST_ENV.MAX_OUTPUT_CHARACTERS) ?? DEFAULT_MAX_OUTPUT_CHARACTERS,
2045
+ maxReadFileBytes: readIntegerEnv(SINGLE_HOST_ENV.MAX_READ_FILE_BYTES) ?? DEFAULT_MAX_READ_FILE_BYTES,
2149
2046
  allowCommands,
2150
- denyPatterns: compileDenyPatterns(profileName, denyPatternSources),
2151
- allowedPaths
2047
+ denyPatterns: compileDenyPatterns(readListEnv(SINGLE_HOST_ENV.DENY_PATTERNS) ?? []),
2048
+ allowedPaths: readListEnv(SINGLE_HOST_ENV.ALLOWED_PATHS) ?? []
2152
2049
  };
2153
- }
2154
- function resolveSecret(profileName, inlineValue, envVarName) {
2155
- if (envVarName == null) {
2156
- return inlineValue;
2157
- }
2158
- const fromEnv = process.env[envVarName];
2159
- if (fromEnv == null || fromEnv === "") {
2160
- const _logger = $logger.child({ context: "resolveSecret", profile: profileName, env_var: envVarName });
2161
- _logger.warn("env var referenced by the profile is empty; falling back to the inline value");
2162
- return inlineValue;
2163
- }
2164
- return fromEnv;
2165
- }
2166
- function toHostProfile(name, raw, defaults) {
2167
2050
  const profile = {
2168
2051
  name,
2169
- host: raw.host,
2170
- port: raw.port ?? DEFAULT_SSH_PORT,
2171
- username: raw.username,
2172
- description: raw.description,
2173
- privateKey: raw.privateKey,
2174
- passphrase: resolveSecret(name, raw.passphrase, raw.passphraseEnv),
2175
- password: resolveSecret(name, raw.password, raw.passwordEnv),
2176
- hostKeyFingerprint: raw.hostKeyFingerprint,
2177
- defaultCwd: raw.defaultCwd,
2178
- policy: resolvePolicy(name, defaults, raw)
2052
+ host,
2053
+ port: readIntegerEnv(SINGLE_HOST_ENV.PORT) ?? DEFAULT_SSH_PORT,
2054
+ username,
2055
+ description: readEnv(SINGLE_HOST_ENV.DESCRIPTION),
2056
+ passphrase: readEnv(SINGLE_HOST_ENV.PASSPHRASE),
2057
+ password: readEnv(SINGLE_HOST_ENV.PASSWORD),
2058
+ hostKeyFingerprint: readEnv(SINGLE_HOST_ENV.HOST_KEY),
2059
+ defaultCwd: readEnv(SINGLE_HOST_ENV.CWD),
2060
+ policy
2179
2061
  };
2180
- if (raw.privateKeyPath != null) {
2181
- const expandedKeyPath = expandHomePath(raw.privateKeyPath);
2062
+ const keyPath = readEnv(SINGLE_HOST_ENV.KEY);
2063
+ if (keyPath != null) {
2064
+ const expandedKeyPath = expandHomePath(keyPath);
2182
2065
  profile.privateKeyPath = expandedKeyPath;
2183
- if (fs4.existsSync(expandedKeyPath) == false) {
2184
- const _logger = $logger.child({ context: "toHostProfile", profile: name });
2185
- _logger.warn("privateKeyPath does not exist; connections for this profile will fail", {
2066
+ if (fs.existsSync(expandedKeyPath) == false) {
2067
+ _logger.warn(`${SINGLE_HOST_ENV.KEY} does not exist; connections will fail`, {
2186
2068
  private_key_path: expandedKeyPath
2187
2069
  });
2188
2070
  }
2189
2071
  }
2190
- return profile;
2191
- }
2192
- function warnOnLoosePermissions(configPath) {
2193
- if (process.platform === "win32") {
2194
- return;
2195
- }
2196
- const stats = fs4.statSync(configPath);
2197
- const permissionBits = stats.mode & 511;
2198
- if ((permissionBits & 63) === 0) {
2199
- return;
2200
- }
2201
- const _logger = $logger.child({ context: "warnOnLoosePermissions", config_path: configPath });
2202
- _logger.warn(`config file is readable by other users, run: chmod 600 ${configPath}`, {
2203
- mode: permissionBits.toString(8)
2204
- });
2205
- }
2206
- function loadConfig(explicitPath) {
2207
- let _logger = $logger.child({ context: "loadConfig" });
2208
- const source = resolveConfigSource(explicitPath);
2209
- if (source.kind === "none") {
2210
- _logger.warn("no config found; no host profiles are registered", {
2211
- searched: DEFAULT_CONFIG_FILENAMES,
2212
- env_vars: [SINGLE_HOST_ENV.HOST, CONFIG_PATH_ENV]
2213
- });
2214
- return { profiles: /* @__PURE__ */ new Map(), sourcePath: null, sourceLabel: "none" };
2215
- }
2216
- let parsedJson = null;
2217
- let sourcePath = null;
2218
- let sourceLabel = `inline ${CONFIG_PATH_ENV}`;
2219
- if (source.kind === "env") {
2220
- _logger = _logger.child({ config_source: "env" });
2221
- sourceLabel = `${SINGLE_HOST_ENV.HOST} environment variables`;
2222
- parsedJson = source.config;
2223
- } else {
2224
- let rawText = "";
2225
- if (source.kind === "inline") {
2226
- _logger = _logger.child({ config_source: "inline" });
2227
- rawText = source.json;
2228
- } else {
2229
- _logger = _logger.child({ config_path: source.path });
2230
- sourcePath = source.path;
2231
- sourceLabel = source.path;
2232
- if (fs4.existsSync(source.path) == false) {
2233
- _logger.error(null, "config file does not exist");
2234
- throw new Error(`ssh mcp config not found: ${source.path}`);
2235
- }
2236
- rawText = fs4.readFileSync(source.path, "utf8");
2237
- }
2238
- try {
2239
- parsedJson = JSON.parse(rawText);
2240
- } catch (ex) {
2241
- _logger.error(ex, "config is not valid json");
2242
- throw new Error(`ssh mcp config is not valid JSON (${sourceLabel})`);
2243
- }
2244
- }
2245
- const validated = CONFIG_INPUT_SCHEMA.safeParse(parsedJson);
2246
- if (validated.success == false) {
2247
- const issueSummary = validated.error.issues.map((issue) => `${issue.path.join(".")}: ${issue.message}`).join("; ");
2248
- _logger.error(validated.error, "config failed schema validation");
2249
- throw new Error(`ssh mcp config is invalid (${sourceLabel}): ${issueSummary}`);
2250
- }
2251
- if (sourcePath != null) {
2252
- warnOnLoosePermissions(sourcePath);
2253
- }
2254
- const profiles = /* @__PURE__ */ new Map();
2255
- for (const [name, hostInput] of Object.entries(validated.data.hosts)) {
2256
- profiles.set(name, toHostProfile(name, hostInput, validated.data.defaults));
2257
- }
2258
- _logger.info("config loaded", {
2259
- profile_count: profiles.size,
2260
- profiles: Array.from(profiles.keys())
2072
+ _logger.info("host profile loaded", {
2073
+ host,
2074
+ port: profile.port,
2075
+ username,
2076
+ readonly: policy.readonly
2261
2077
  });
2262
- return { profiles, sourcePath, sourceLabel };
2078
+ return profile;
2263
2079
  }
2264
2080
  function toHostSummary(profile) {
2265
2081
  let authMethod = "agent";
2266
- if (profile.privateKeyPath != null || profile.privateKey != null) {
2082
+ if (profile.privateKeyPath != null) {
2267
2083
  authMethod = "privateKey";
2268
2084
  } else if (profile.password != null) {
2269
2085
  authMethod = "password";
@@ -2289,44 +2105,42 @@ var ENTRY_TYPE_MARKERS = {
2289
2105
  symlink: "l",
2290
2106
  other: "?"
2291
2107
  };
2292
- function formatHostList(config) {
2293
- if (config.profiles.size === 0) {
2108
+ function formatHostProfile(profile) {
2109
+ if (profile == null) {
2294
2110
  return [
2295
- "No SSH host profiles are registered.",
2111
+ "No SSH host is registered.",
2296
2112
  "",
2297
- `Put them in this server's MCP entry \u2014 ${CONFIG_PATH_ENV} takes the JSON itself:`,
2113
+ "Put the connection in this server's MCP entry \u2014 one server entry per host:",
2298
2114
  "",
2299
2115
  ` "env": {`,
2300
- ` "${CONFIG_PATH_ENV}": "{\\"hosts\\":{\\"my-server\\":{\\"host\\":\\"10.0.0.5\\",\\"username\\":\\"deploy\\",\\"privateKeyPath\\":\\"~/.ssh/id_ed25519\\"}}}"`,
2116
+ ` "${SINGLE_HOST_ENV.HOST}": "10.0.0.5",`,
2117
+ ` "${SINGLE_HOST_ENV.USER}": "deploy",`,
2118
+ ` "${SINGLE_HOST_ENV.KEY}": "~/.ssh/id_ed25519"`,
2301
2119
  " }",
2302
2120
  "",
2303
- `It also accepts a path to a JSON file, and ~/${DEFAULT_CONFIG_FILENAMES[0]} is picked up automatically.`,
2304
- "Restart this server after changing it."
2121
+ `${SINGLE_HOST_ENV.HOST} and ${SINGLE_HOST_ENV.USER} are the whole minimum.`,
2122
+ "Restart this server after changing it \u2014 environment variables are fixed for the life of the process."
2305
2123
  ].join("\n");
2306
2124
  }
2125
+ const summary = toHostSummary(profile);
2126
+ let accessLabel = "writable";
2127
+ if (summary.readonly == true) {
2128
+ accessLabel = "READ-ONLY";
2129
+ }
2130
+ let sudoLabel = "sudo=disabled";
2131
+ if (summary.allowSudo == true) {
2132
+ sudoLabel = "sudo=enabled";
2133
+ }
2307
2134
  const lines = [];
2308
- lines.push(`${config.profiles.size} SSH host profile(s) from ${config.sourceLabel}:`);
2309
- for (const profile of config.profiles.values()) {
2310
- const summary = toHostSummary(profile);
2311
- let accessLabel = "writable";
2312
- if (summary.readonly == true) {
2313
- accessLabel = "READ-ONLY";
2314
- }
2315
- let sudoLabel = "sudo=disabled";
2316
- if (summary.allowSudo == true) {
2317
- sudoLabel = "sudo=enabled";
2318
- }
2319
- lines.push("");
2320
- lines.push(`${summary.name} \u2192 ${summary.username}@${summary.host}:${summary.port} auth=${summary.authMethod} ${accessLabel} ${sudoLabel}`);
2321
- if (summary.description != null) {
2322
- lines.push(` ${summary.description}`);
2323
- }
2324
- if (summary.allowedPaths.length > 0) {
2325
- lines.push(` file tools limited to: ${summary.allowedPaths.join(", ")} (does not restrict ssh_exec)`);
2326
- }
2327
- if (summary.allowCommands.length > 0) {
2328
- lines.push(` commands limited to: ${summary.allowCommands.join(", ")}`);
2329
- }
2135
+ lines.push(`${summary.name} \u2192 ${summary.username}@${summary.host}:${summary.port} auth=${summary.authMethod} ${accessLabel} ${sudoLabel}`);
2136
+ if (summary.description != null) {
2137
+ lines.push(` ${summary.description}`);
2138
+ }
2139
+ if (summary.allowedPaths.length > 0) {
2140
+ lines.push(` file tools limited to: ${summary.allowedPaths.join(", ")} (does not restrict ssh_exec)`);
2141
+ }
2142
+ if (summary.allowCommands.length > 0) {
2143
+ lines.push(` commands limited to: ${summary.allowCommands.join(", ")}`);
2330
2144
  }
2331
2145
  return lines.join("\n");
2332
2146
  }
@@ -2438,40 +2252,16 @@ function toToolError(error, toolName, action) {
2438
2252
  _logger.error(error, `${toolName} failed`);
2439
2253
  return errorResult(`${toolName} failed: ${describeError(error)}`);
2440
2254
  }
2441
- function requireProfile(context, target) {
2442
- const profile = context.config.profiles.get(target);
2443
- if (profile != null) {
2444
- return profile;
2445
- }
2446
- const available = Array.from(context.config.profiles.keys());
2447
- if (available.length === 0) {
2448
- 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`);
2449
- }
2450
- throw new Error(`unknown host profile '${target}' (registered: ${available.join(", ")})`);
2451
- }
2452
2255
  function resolveProfile(context, args) {
2453
- const hasTarget = args.target != null && args.target.trim() !== "";
2454
2256
  const hasSession = args.session != null && args.session.trim() !== "";
2455
- if (hasTarget == true && hasSession == true) {
2456
- throw new Error("pass either 'target' or 'session', not both \u2014 they can name different hosts with different policies");
2457
- }
2458
2257
  if (hasSession == true) {
2459
2258
  const session = context.sessions.require(args.session.trim());
2460
2259
  return { profile: session.profile, session };
2461
2260
  }
2462
- if (hasTarget == true) {
2463
- const profile = requireProfile(context, args.target.trim());
2464
- return { profile, session: null };
2465
- }
2466
- const onlyProfile = context.config.profiles.values().next().value;
2467
- if (context.config.profiles.size === 1 && onlyProfile != null) {
2468
- return { profile: onlyProfile, session: null };
2469
- }
2470
- if (context.config.profiles.size === 0) {
2471
- 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`);
2261
+ if (context.profile == null) {
2262
+ 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`);
2472
2263
  }
2473
- const available = Array.from(context.config.profiles.keys());
2474
- throw new Error(`this server fronts several hosts, so 'target' is required (registered: ${available.join(", ")})`);
2264
+ return { profile: context.profile, session: null };
2475
2265
  }
2476
2266
  async function withHostConnection(profile, session, operation) {
2477
2267
  if (session != null) {
@@ -2492,12 +2282,6 @@ async function withHostConnection(profile, session, operation) {
2492
2282
  }
2493
2283
  }
2494
2284
  }
2495
- function protectedLocalPathsOf(context) {
2496
- if (context.config.sourcePath == null) {
2497
- return [];
2498
- }
2499
- return [context.config.sourcePath];
2500
- }
2501
2285
 
2502
2286
  // src/modules/tools/exec-tools.ts
2503
2287
  function registerExecTools(server, context) {
@@ -2506,14 +2290,13 @@ function registerExecTools(server, context) {
2506
2290
  {
2507
2291
  title: "Run a command over SSH",
2508
2292
  description: [
2509
- "Run a shell command on a registered SSH host and return stdout, stderr and the exit code.",
2510
- "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.",
2293
+ "Run a shell command on the configured SSH host and return stdout, stderr and the exit code.",
2294
+ "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.",
2511
2295
  "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.",
2512
- "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."
2296
+ "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."
2513
2297
  ].join(" "),
2514
2298
  inputSchema: {
2515
2299
  command: 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."),
2516
- target: 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."),
2517
2300
  session: z.string().optional().describe("Session id from ssh_connect. Reuses that connection and keeps the working directory across calls."),
2518
2301
  cwd: z.string().optional().describe("Absolute directory to run in, with no shell metacharacters. With a session, the session also moves there."),
2519
2302
  timeoutMs: 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.")
@@ -2525,10 +2308,10 @@ function registerExecTools(server, context) {
2525
2308
  openWorldHint: true
2526
2309
  }
2527
2310
  },
2528
- async ({ command, target, session, cwd, timeoutMs }) => {
2311
+ async ({ command, session, cwd, timeoutMs }) => {
2529
2312
  let _logger = $logger.child({ context: "ssh_exec" });
2530
2313
  try {
2531
- const { profile, session: openSession } = resolveProfile(context, { target, session });
2314
+ const { profile, session: openSession } = resolveProfile(context, { session });
2532
2315
  _logger = _logger.child({ profile: profile.name, session_id: openSession?.sessionId });
2533
2316
  const requestedTimeoutMs = timeoutMs ?? profile.policy.execTimeoutMs;
2534
2317
  const effectiveTimeoutMs = Math.min(requestedTimeoutMs, profile.policy.execTimeoutMs);
@@ -2564,10 +2347,9 @@ function registerExecTools(server, context) {
2564
2347
  }
2565
2348
  );
2566
2349
  }
2567
- var TARGET_DESCRIPTION = "Host profile alias. Omit it when this server fronts a single host, which is the usual setup.";
2568
- var SESSION_DESCRIPTION = "Session id from ssh_connect, whose connection is reused. Pass this or 'target', not both.";
2569
- function requirePermittedLocalPath(context, localPath, access) {
2570
- const verdict = inspectLocalPath(localPath, access, protectedLocalPathsOf(context));
2350
+ var SESSION_DESCRIPTION = "Session id from ssh_connect, whose connection is reused. Omit it to open a one-off connection.";
2351
+ function requirePermittedLocalPath(localPath, access) {
2352
+ const verdict = inspectLocalPath(localPath, access);
2571
2353
  if (verdict.allowed == false) {
2572
2354
  throw new GuardRejectionError(verdict);
2573
2355
  }
@@ -2578,12 +2360,11 @@ function registerFileTools(server, context) {
2578
2360
  {
2579
2361
  title: "List a remote directory",
2580
2362
  description: [
2581
- "List a directory on a registered SSH host over SFTP, with type, permissions, size and mtime.",
2582
- "Cheaper and more structured than running 'ls -la' through ssh_exec, and it works on read-only profiles."
2363
+ "List a directory on the configured SSH host over SFTP, with type, permissions, size and mtime.",
2364
+ "Cheaper and more structured than running 'ls -la' through ssh_exec, and it works on a read-only host."
2583
2365
  ].join(" "),
2584
2366
  inputSchema: {
2585
- path: z.string().min(1).describe("Absolute remote directory path. Profiles with allowedPaths set accept only paths inside them."),
2586
- target: z.string().optional().describe(TARGET_DESCRIPTION),
2367
+ path: z.string().min(1).describe("Absolute remote directory path. When SSH_ALLOWED_PATHS is set, only paths inside it are accepted."),
2587
2368
  session: z.string().optional().describe(SESSION_DESCRIPTION)
2588
2369
  },
2589
2370
  annotations: {
@@ -2593,10 +2374,10 @@ function registerFileTools(server, context) {
2593
2374
  openWorldHint: true
2594
2375
  }
2595
2376
  },
2596
- async ({ path: remotePath, target, session }) => {
2377
+ async ({ path: remotePath, session }) => {
2597
2378
  const _logger = $logger.child({ context: "ssh_list_dir", remote_path: remotePath });
2598
2379
  try {
2599
- const { profile, session: openSession } = resolveProfile(context, { target, session });
2380
+ const { profile, session: openSession } = resolveProfile(context, { session });
2600
2381
  const listing = await withHostConnection(profile, openSession, async (connection) => {
2601
2382
  return await listRemoteDirectory(connection, remotePath);
2602
2383
  });
@@ -2623,9 +2404,8 @@ function registerFileTools(server, context) {
2623
2404
  ].join(" "),
2624
2405
  inputSchema: {
2625
2406
  path: z.string().min(1).describe("Absolute remote file path."),
2626
- target: z.string().optional().describe(TARGET_DESCRIPTION),
2627
2407
  session: z.string().optional().describe(SESSION_DESCRIPTION),
2628
- maxBytes: z.number().int().positive().optional().describe("Lowers the read ceiling for this call; it cannot exceed the profile's maxReadFileBytes.")
2408
+ maxBytes: z.number().int().positive().optional().describe("Lowers the read ceiling for this call; it cannot exceed SSH_MAX_READ_BYTES.")
2629
2409
  },
2630
2410
  annotations: {
2631
2411
  readOnlyHint: true,
@@ -2634,10 +2414,10 @@ function registerFileTools(server, context) {
2634
2414
  openWorldHint: true
2635
2415
  }
2636
2416
  },
2637
- async ({ path: remotePath, target, session, maxBytes }) => {
2417
+ async ({ path: remotePath, session, maxBytes }) => {
2638
2418
  const _logger = $logger.child({ context: "ssh_read_file", remote_path: remotePath });
2639
2419
  try {
2640
- const { profile, session: openSession } = resolveProfile(context, { target, session });
2420
+ const { profile, session: openSession } = resolveProfile(context, { session });
2641
2421
  const requestedLimit = maxBytes ?? profile.policy.maxReadFileBytes;
2642
2422
  const readLimit = Math.min(requestedLimit, profile.policy.maxReadFileBytes);
2643
2423
  const file = await withHostConnection(profile, openSession, async (connection) => {
@@ -2669,14 +2449,13 @@ ${file.content}`);
2669
2449
  {
2670
2450
  title: "Write a remote file",
2671
2451
  description: [
2672
- "Write or append UTF-8 text to a file on a registered SSH host over SFTP.",
2452
+ "Write or append UTF-8 text to a file on the configured SSH host over SFTP.",
2673
2453
  "Use this instead of 'echo ... > file' through ssh_exec: no shell quoting to get wrong, and it works with multi-line content.",
2674
- "Refused on read-only profiles. The parent directory must already exist."
2454
+ "Refused on a read-only host. The parent directory must already exist."
2675
2455
  ].join(" "),
2676
2456
  inputSchema: {
2677
2457
  path: z.string().min(1).describe("Absolute remote file path. Existing content is replaced unless 'append' is true."),
2678
2458
  content: z.string().describe("UTF-8 text to write."),
2679
- target: z.string().optional().describe(TARGET_DESCRIPTION),
2680
2459
  session: z.string().optional().describe(SESSION_DESCRIPTION),
2681
2460
  append: z.boolean().optional().describe("Append instead of replacing. Defaults to false.")
2682
2461
  },
@@ -2687,10 +2466,10 @@ ${file.content}`);
2687
2466
  openWorldHint: true
2688
2467
  }
2689
2468
  },
2690
- async ({ path: remotePath, content, target, session, append }) => {
2469
+ async ({ path: remotePath, content, session, append }) => {
2691
2470
  const _logger = $logger.child({ context: "ssh_write_file", remote_path: remotePath });
2692
2471
  try {
2693
- const { profile, session: openSession } = resolveProfile(context, { target, session });
2472
+ const { profile, session: openSession } = resolveProfile(context, { session });
2694
2473
  const shouldAppend = append ?? false;
2695
2474
  const writtenBytes = await withHostConnection(profile, openSession, async (connection) => {
2696
2475
  return await writeRemoteFile(connection, {
@@ -2715,14 +2494,13 @@ ${file.content}`);
2715
2494
  {
2716
2495
  title: "Upload a file over SFTP",
2717
2496
  description: [
2718
- "Upload a local file to a registered SSH host over SFTP.",
2719
- "Refused on read-only profiles, and for local paths holding credentials (SSH keys, cloud credentials, this server's own config).",
2497
+ "Upload a local file to the configured SSH host over SFTP.",
2498
+ "Refused on a read-only host, and for local paths holding credentials (SSH keys, cloud credentials).",
2720
2499
  "The remote parent directory must already exist."
2721
2500
  ].join(" "),
2722
2501
  inputSchema: {
2723
2502
  localPath: z.string().min(1).describe("Local file path on the machine running this MCP server. '~' is expanded."),
2724
2503
  remotePath: z.string().min(1).describe("Absolute destination path on the remote host, including the filename."),
2725
- target: z.string().optional().describe(TARGET_DESCRIPTION),
2726
2504
  session: z.string().optional().describe(SESSION_DESCRIPTION)
2727
2505
  },
2728
2506
  annotations: {
@@ -2732,13 +2510,13 @@ ${file.content}`);
2732
2510
  openWorldHint: true
2733
2511
  }
2734
2512
  },
2735
- async ({ localPath, remotePath, target, session }) => {
2513
+ async ({ localPath, remotePath, session }) => {
2736
2514
  const _logger = $logger.child({ context: "ssh_upload", remote_path: remotePath });
2737
2515
  try {
2738
- const { profile, session: openSession } = resolveProfile(context, { target, session });
2516
+ const { profile, session: openSession } = resolveProfile(context, { session });
2739
2517
  const resolvedLocalPath = expandHomePath(localPath);
2740
- requirePermittedLocalPath(context, resolvedLocalPath, "read");
2741
- if (fs4.existsSync(resolvedLocalPath) == false) {
2518
+ requirePermittedLocalPath(resolvedLocalPath, "read");
2519
+ if (fs.existsSync(resolvedLocalPath) == false) {
2742
2520
  return textResult(`local file not found: ${resolvedLocalPath}`);
2743
2521
  }
2744
2522
  const transferredBytes = await withHostConnection(profile, openSession, async (connection) => {
@@ -2756,14 +2534,13 @@ ${file.content}`);
2756
2534
  {
2757
2535
  title: "Download a file over SFTP",
2758
2536
  description: [
2759
- "Download a file from a registered SSH host to the machine running this MCP server.",
2537
+ "Download a file from the configured SSH host to the machine running this MCP server.",
2760
2538
  "Use this for binaries or files too large to read into the conversation; missing local directories are created.",
2761
- "The local destination is overwritten, and paths holding credentials or this server's own configuration are refused."
2539
+ "The local destination is overwritten, and paths holding credentials are refused."
2762
2540
  ].join(" "),
2763
2541
  inputSchema: {
2764
2542
  remotePath: z.string().min(1).describe("Absolute source path on the remote host."),
2765
2543
  localPath: z.string().min(1).describe("Local destination path, including the filename. '~' is expanded. Overwritten if it exists."),
2766
- target: z.string().optional().describe(TARGET_DESCRIPTION),
2767
2544
  session: z.string().optional().describe(SESSION_DESCRIPTION)
2768
2545
  },
2769
2546
  annotations: {
@@ -2773,13 +2550,13 @@ ${file.content}`);
2773
2550
  openWorldHint: true
2774
2551
  }
2775
2552
  },
2776
- async ({ remotePath, localPath, target, session }) => {
2553
+ async ({ remotePath, localPath, session }) => {
2777
2554
  const _logger = $logger.child({ context: "ssh_download", remote_path: remotePath });
2778
2555
  try {
2779
- const { profile, session: openSession } = resolveProfile(context, { target, session });
2556
+ const { profile, session: openSession } = resolveProfile(context, { session });
2780
2557
  const resolvedLocalPath = expandHomePath(localPath);
2781
- requirePermittedLocalPath(context, resolvedLocalPath, "write");
2782
- fs4.mkdirSync(path2.dirname(resolvedLocalPath), { recursive: true });
2558
+ requirePermittedLocalPath(resolvedLocalPath, "write");
2559
+ fs.mkdirSync(path2.dirname(resolvedLocalPath), { recursive: true });
2783
2560
  const transferredBytes = await withHostConnection(profile, openSession, async (connection) => {
2784
2561
  return await downloadFile(connection, remotePath, resolvedLocalPath);
2785
2562
  });
@@ -2797,11 +2574,11 @@ function registerHostTools(server, context) {
2797
2574
  server.registerTool(
2798
2575
  "ssh_list_hosts",
2799
2576
  {
2800
- title: "List SSH host profiles",
2577
+ title: "Show the SSH host",
2801
2578
  description: [
2802
- "List the SSH hosts this server may connect to, with each host's guard policy.",
2803
- "Call this first: every other tool takes a profile alias as its 'target', and no host outside this list can be reached.",
2804
- "Credentials are never returned \u2014 only which auth method the profile uses."
2579
+ "Show the SSH host this server connects to, with its guard policy.",
2580
+ "Call this first: it is the only reachable host, and its policy decides what the other tools will accept.",
2581
+ "Credentials are never returned \u2014 only which auth method the host uses."
2805
2582
  ].join(" "),
2806
2583
  inputSchema: {},
2807
2584
  annotations: {
@@ -2814,59 +2591,11 @@ function registerHostTools(server, context) {
2814
2591
  async () => {
2815
2592
  const _logger = $logger.child({ context: "ssh_list_hosts" });
2816
2593
  try {
2817
- const listing = formatHostList(context.config);
2818
- _logger.debug("host list returned", { profile_count: context.config.profiles.size });
2594
+ const listing = formatHostProfile(context.profile);
2595
+ _logger.debug("host profile returned", { profile: context.profile?.name });
2819
2596
  return textResult(listing);
2820
2597
  } catch (ex) {
2821
- return toToolError(ex, "ssh_list_hosts", "list hosts");
2822
- }
2823
- }
2824
- );
2825
- server.registerTool(
2826
- "ssh_reload_config",
2827
- {
2828
- title: "Reload the host configuration",
2829
- description: [
2830
- "Re-read the host configuration and apply it, without restarting the MCP server.",
2831
- "Use after the operator edits the config file \u2014 adding a host, tightening a policy.",
2832
- "Every open session is closed first, so the new policy is in force everywhere rather than leaving old sessions on the old rules.",
2833
- "Configuration supplied through environment variables is fixed for the life of the process; changing that still needs a restart."
2834
- ].join(" "),
2835
- inputSchema: {},
2836
- annotations: {
2837
- readOnlyHint: false,
2838
- destructiveHint: false,
2839
- idempotentHint: true,
2840
- openWorldHint: false
2841
- }
2842
- },
2843
- async () => {
2844
- const _logger = $logger.child({ context: "ssh_reload_config" });
2845
- try {
2846
- const previousLabel = context.config.sourceLabel;
2847
- const closedSessions = context.sessions.list().length;
2848
- context.sessions.closeAll();
2849
- const reloaded = context.reloadConfig();
2850
- const lines = [
2851
- `Reloaded ${reloaded.profiles.size} host profile(s) from ${reloaded.sourceLabel}.`
2852
- ];
2853
- if (reloaded.profiles.size > 0) {
2854
- lines.push(`Hosts: ${Array.from(reloaded.profiles.keys()).join(", ")}`);
2855
- }
2856
- if (closedSessions > 0) {
2857
- lines.push(`Closed ${closedSessions} open session(s); reopen with ssh_connect if needed.`);
2858
- }
2859
- if (reloaded.sourcePath == null) {
2860
- 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.");
2861
- }
2862
- _logger.info("config reloaded", {
2863
- profile_count: reloaded.profiles.size,
2864
- previous_source: previousLabel,
2865
- config_source: reloaded.sourceLabel
2866
- });
2867
- return textResult(lines.join("\n"));
2868
- } catch (ex) {
2869
- return toToolError(ex, "ssh_reload_config", "reload the configuration");
2598
+ return toToolError(ex, "ssh_list_hosts", "show the host");
2870
2599
  }
2871
2600
  }
2872
2601
  );
@@ -2877,13 +2606,12 @@ function registerSessionTools(server, context) {
2877
2606
  {
2878
2607
  title: "Open an SSH session",
2879
2608
  description: [
2880
- "Open a reusable SSH session to a registered host and return its session id.",
2881
- "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.",
2609
+ "Open a reusable SSH session to the configured host and return its session id.",
2610
+ "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.",
2882
2611
  "Close it with ssh_disconnect when done; idle sessions are reaped automatically, and a session whose connection drops reports that rather than silently reconnecting."
2883
2612
  ].join(" "),
2884
2613
  inputSchema: {
2885
- target: z.string().optional().describe("Host profile alias. Omit it when this server fronts a single host, which is the usual setup."),
2886
- cwd: 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.")
2614
+ cwd: z.string().optional().describe("Absolute directory to start in, with no shell metacharacters. Defaults to SSH_CWD, else the login shell's directory.")
2887
2615
  },
2888
2616
  annotations: {
2889
2617
  readOnlyHint: false,
@@ -2892,11 +2620,11 @@ function registerSessionTools(server, context) {
2892
2620
  openWorldHint: true
2893
2621
  }
2894
2622
  },
2895
- async ({ target, cwd }) => {
2896
- let _logger = $logger.child({ context: "ssh_connect", target });
2623
+ async ({ cwd }) => {
2624
+ let _logger = $logger.child({ context: "ssh_connect" });
2897
2625
  let openedSessionId = null;
2898
2626
  try {
2899
- const { profile } = resolveProfile(context, { target });
2627
+ const { profile } = resolveProfile(context, {});
2900
2628
  const session = await context.sessions.open(profile);
2901
2629
  openedSessionId = session.sessionId;
2902
2630
  _logger = _logger.child({ session_id: session.sessionId });
@@ -2921,7 +2649,7 @@ ${probe.stderr.trim()}`);
2921
2649
  `cwd: ${session.cwd}`
2922
2650
  ];
2923
2651
  if (profile.policy.readonly == true) {
2924
- lines.push("This profile is READ-ONLY: writes, redirections and uploads will be rejected.");
2652
+ lines.push("This host is READ-ONLY: writes, redirections and uploads will be rejected.");
2925
2653
  }
2926
2654
  lines.push(`Run commands with ssh_exec({ session: "${session.sessionId}", command: "..." }).`);
2927
2655
  _logger.info("session opened via tool", { cwd: session.cwd });
@@ -2930,7 +2658,7 @@ ${probe.stderr.trim()}`);
2930
2658
  if (openedSessionId != null) {
2931
2659
  context.sessions.close(openedSessionId);
2932
2660
  }
2933
- return toToolError(ex, "ssh_connect", `connect to ${target ?? "the configured host"}`);
2661
+ return toToolError(ex, "ssh_connect", "connect to the configured host");
2934
2662
  }
2935
2663
  }
2936
2664
  );
@@ -2998,31 +2726,30 @@ function registerAllTools(server, context) {
2998
2726
 
2999
2727
  // src/server.ts
3000
2728
  var SERVER_INSTRUCTIONS = [
3001
- "This server runs shell commands and transfers files on remote hosts over SSH.",
3002
- "Only hosts pre-registered in a local config file are reachable \u2014 arbitrary host/credential arguments are not accepted.",
2729
+ "This server runs shell commands and transfers files on one remote host over SSH.",
2730
+ "The host is fixed in this server's own environment \u2014 there is no host argument, and no other machine is reachable through it.",
3003
2731
  "",
3004
- "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.",
2732
+ "ssh_list_hosts shows which host this is and what it permits. Call it before assuming anything about access.",
3005
2733
  "",
3006
2734
  "Workflow:",
3007
2735
  "1. One command: ssh_exec with just 'command'.",
3008
2736
  "2. Several commands in a row: ssh_connect once, then ssh_exec with 'session' (the working directory persists across calls), then ssh_disconnect.",
3009
2737
  "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.",
3010
- "Pass 'target' only when ssh_list_hosts shows several hosts, and never together with 'session'.",
3011
2738
  "",
3012
2739
  "What is permitted: ordinary administration, without asking \u2014 package installs, service restarts, sudo, interpreters, config edits, deployments.",
3013
- "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.",
3014
- "Individual profiles may add their own limits (read-only, an allow-list, deny patterns, path restrictions). ssh_list_hosts shows which apply where.",
2740
+ "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.",
2741
+ "The host may add its own limits (read-only, an allow-list, deny patterns, path restrictions). ssh_list_hosts shows which apply.",
3015
2742
  "",
3016
2743
  "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.",
3017
2744
  "A job that may outlive its timeout should be started detached (nohup \u2026 &, tmux new -d) and polled through its log.",
3018
2745
  "",
3019
2746
  "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.",
3020
2747
  "A non-zero exit code is returned as normal output; read it and stderr to judge what happened.",
3021
- "You still carry the judgement the guards do not: these are real hosts, and an irreversible action deserves a check with the operator first."
2748
+ "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."
3022
2749
  ].join("\n");
3023
- function createSshMcpServer(options) {
2750
+ function createSshMcpServer() {
3024
2751
  const _logger = $logger.child({ context: "createSshMcpServer" });
3025
- const config = loadConfig(options?.configPath);
2752
+ const profile = loadHostProfile();
3026
2753
  const sessions = new SshSessionManager();
3027
2754
  sessions.startIdleSweeper();
3028
2755
  const server = new McpServer(
@@ -3035,28 +2762,15 @@ function createSshMcpServer(options) {
3035
2762
  }
3036
2763
  );
3037
2764
  const context = {
3038
- config,
3039
- sessions,
3040
- reloadConfig() {
3041
- const reloaded = loadConfig(options?.configPath);
3042
- context.config = reloaded;
3043
- return reloaded;
3044
- }
2765
+ profile,
2766
+ sessions
3045
2767
  };
3046
2768
  registerAllTools(server, context);
3047
- _logger.info("mcp server built", {
3048
- profile_count: config.profiles.size,
3049
- config_source: config.sourceLabel
3050
- });
2769
+ _logger.info("mcp server built", { profile: profile?.name ?? null });
3051
2770
  return {
3052
2771
  server,
3053
2772
  sessions,
3054
- get config() {
3055
- return context.config;
3056
- },
3057
- reloadConfig() {
3058
- return context.reloadConfig();
3059
- },
2773
+ profile,
3060
2774
  shutdown() {
3061
2775
  sessions.stopIdleSweeper();
3062
2776
  sessions.closeAll();
@@ -3067,8 +2781,7 @@ function createSshMcpServer(options) {
3067
2781
  // src/cli.ts
3068
2782
  function parseArguments(argv) {
3069
2783
  const parsed = { showHelp: false, showVersion: false, checkOnly: false };
3070
- for (let index = 0; index < argv.length; index++) {
3071
- const argument = argv[index];
2784
+ for (const argument of argv) {
3072
2785
  if (argument === "--help" || argument === "-h") {
3073
2786
  parsed.showHelp = true;
3074
2787
  continue;
@@ -3081,23 +2794,6 @@ function parseArguments(argv) {
3081
2794
  parsed.checkOnly = true;
3082
2795
  continue;
3083
2796
  }
3084
- if (argument === "--config" || argument === "-c") {
3085
- const value = argv[index + 1];
3086
- if (value == null || value.startsWith("-") == true) {
3087
- throw new Error(`${argument} requires a file path`);
3088
- }
3089
- parsed.configPath = value;
3090
- index++;
3091
- continue;
3092
- }
3093
- if (argument.startsWith("--config=") == true) {
3094
- const value = argument.slice("--config=".length);
3095
- if (value === "") {
3096
- throw new Error("--config= requires a file path");
3097
- }
3098
- parsed.configPath = value;
3099
- continue;
3100
- }
3101
2797
  }
3102
2798
  return parsed;
3103
2799
  }
@@ -3105,20 +2801,32 @@ var HELP_TEXT = [
3105
2801
  `${SERVER_NAME} ${SERVER_VERSION} \u2014 MCP server for SSH command execution and SFTP transfers`,
3106
2802
  "",
3107
2803
  "Usage:",
3108
- " akms-ssh [--config <path>]",
2804
+ " akms-mcp-ssh [--check]",
3109
2805
  "",
3110
2806
  "Options:",
3111
- " -c, --config <path> Host profile config file",
3112
- ` (default: ${CONFIG_PATH_ENV} env var, else ~/${DEFAULT_CONFIG_FILENAMES[0]})`,
3113
- " --check Connect to every profile, report the result, and exit.",
3114
- " Prints each host key fingerprint so it can be pinned.",
2807
+ " --check Connect to the configured host, report the result, and exit.",
2808
+ " Prints the host key fingerprint so it can be pinned.",
3115
2809
  " -v, --version Print the version and exit",
3116
2810
  " -h, --help Print this help and exit",
3117
2811
  "",
3118
- "Environment:",
3119
- ` ${CONFIG_PATH_ENV} Host profiles as JSON, or a path to a JSON file.`,
3120
- " A value starting with '{' is read as the config itself, so an",
3121
- " MCP client can carry the whole setup in its own env block.",
2812
+ "Environment \u2014 one server entry per host, set in the MCP client's env block:",
2813
+ ` ${SINGLE_HOST_ENV.HOST} Hostname or IP (required)`,
2814
+ ` ${SINGLE_HOST_ENV.USER} Login user (required)`,
2815
+ ` ${SINGLE_HOST_ENV.PORT} Default 22`,
2816
+ ` ${SINGLE_HOST_ENV.KEY} Path to a private key file, '~' expanded`,
2817
+ ` ${SINGLE_HOST_ENV.PASSPHRASE} Key passphrase`,
2818
+ ` ${SINGLE_HOST_ENV.PASSWORD} Password auth, when no key is used`,
2819
+ ` ${SINGLE_HOST_ENV.HOST_KEY} Pinned host key, 'SHA256:\u2026'; a mismatch aborts the connection`,
2820
+ ` ${SINGLE_HOST_ENV.NAME} Alias shown to the model; defaults to the hostname`,
2821
+ ` ${SINGLE_HOST_ENV.DESCRIPTION} Shown to the model \u2014 say what the host is for`,
2822
+ ` ${SINGLE_HOST_ENV.CWD} Directory new sessions start in`,
2823
+ ` ${SINGLE_HOST_ENV.READONLY} Reject write commands, redirections, uploads (default false)`,
2824
+ ` ${SINGLE_HOST_ENV.ALLOW_SUDO} Allow sudo / su / doas (default true)`,
2825
+ ` ${SINGLE_HOST_ENV.ALLOW_COMMANDS} Comma-separated allow-list of binaries`,
2826
+ ` ${SINGLE_HOST_ENV.DENY_PATTERNS} Comma-separated extra deny regexes`,
2827
+ ` ${SINGLE_HOST_ENV.ALLOWED_PATHS} Comma-separated path prefixes the file tools may touch`,
2828
+ ` ${SINGLE_HOST_ENV.EXEC_TIMEOUT_MS} / ${SINGLE_HOST_ENV.CONNECT_TIMEOUT_MS}`,
2829
+ ` ${SINGLE_HOST_ENV.MAX_OUTPUT_CHARACTERS} / ${SINGLE_HOST_ENV.MAX_READ_FILE_BYTES}`,
3122
2830
  ` ${LOG_LEVEL_ENV} silent | debug | info | warn | error (default: info, written to stderr)`,
3123
2831
  "",
3124
2832
  "The server speaks MCP over stdio; run it from an MCP client, not interactively."
@@ -3127,79 +2835,56 @@ function describeAuth(profile) {
3127
2835
  if (profile.privateKeyPath != null) {
3128
2836
  return `key ${profile.privateKeyPath}`;
3129
2837
  }
3130
- if (profile.privateKey != null) {
3131
- return "inline key";
3132
- }
3133
2838
  if (profile.password != null) {
3134
2839
  return "password";
3135
2840
  }
3136
2841
  return "ssh agent";
3137
2842
  }
3138
- async function runConnectionCheck(config) {
3139
- if (config.profiles.size === 0) {
3140
- process.stdout.write(`No host profiles are registered (config source: ${config.sourceLabel}).
2843
+ async function runConnectionCheck(profile) {
2844
+ if (profile == null) {
2845
+ process.stdout.write(`No SSH host is registered \u2014 set ${SINGLE_HOST_ENV.HOST} and ${SINGLE_HOST_ENV.USER}.
3141
2846
  `);
3142
2847
  return 1;
3143
2848
  }
3144
- process.stdout.write(`Checking ${config.profiles.size} profile(s) from ${config.sourceLabel}
2849
+ const endpoint = `${profile.username}@${profile.host}:${profile.port}`;
2850
+ const auth = describeAuth(profile);
2851
+ process.stdout.write(`Checking ${profile.name} \u2192 ${endpoint}
3145
2852
 
3146
2853
  `);
3147
- const profiles = Array.from(config.profiles.values());
3148
- const results = await Promise.all(profiles.map(async (profile) => {
3149
- const connection = new SshConnection(profile);
3150
- const startedAt = Date.now();
3151
- try {
3152
- await connection.connect();
3153
- return {
3154
- profile,
3155
- connected: true,
3156
- durationMs: Date.now() - startedAt,
3157
- fingerprint: connection.hostKeyFingerprint,
3158
- detail: ""
3159
- };
3160
- } catch (ex) {
3161
- let detail = String(ex);
3162
- if (ex instanceof Error) {
3163
- detail = ex.message;
3164
- }
3165
- return {
3166
- profile,
3167
- connected: false,
3168
- durationMs: Date.now() - startedAt,
3169
- fingerprint: connection.hostKeyFingerprint,
3170
- detail
3171
- };
3172
- } finally {
3173
- connection.disconnect();
2854
+ const connection = new SshConnection(profile);
2855
+ const startedAt = Date.now();
2856
+ let connected = false;
2857
+ let detail = "";
2858
+ try {
2859
+ await connection.connect();
2860
+ connected = true;
2861
+ } catch (ex) {
2862
+ detail = String(ex);
2863
+ if (ex instanceof Error) {
2864
+ detail = ex.message;
3174
2865
  }
3175
- }));
3176
- let connectedCount = 0;
3177
- for (const result of results) {
3178
- const endpoint = `${result.profile.username}@${result.profile.host}:${result.profile.port}`;
3179
- const auth = describeAuth(result.profile);
3180
- if (result.connected == true) {
3181
- connectedCount += 1;
3182
- process.stdout.write(` OK ${result.profile.name} \u2192 ${endpoint} (${auth}, ${result.durationMs}ms)
2866
+ } finally {
2867
+ connection.disconnect();
2868
+ }
2869
+ const durationMs = Date.now() - startedAt;
2870
+ if (connected == true) {
2871
+ process.stdout.write(` OK ${endpoint} (${auth}, ${durationMs}ms)
3183
2872
  `);
3184
- } else {
3185
- process.stdout.write(` FAILED ${result.profile.name} \u2192 ${endpoint} (${auth})
2873
+ } else {
2874
+ process.stdout.write(` FAILED ${endpoint} (${auth})
3186
2875
  `);
3187
- process.stdout.write(` ${result.detail}
2876
+ process.stdout.write(` ${detail}
3188
2877
  `);
2878
+ }
2879
+ if (connection.hostKeyFingerprint != null) {
2880
+ let pinNote = `not pinned \u2014 set ${SINGLE_HOST_ENV.HOST_KEY} to it to detect a changed key`;
2881
+ if (profile.hostKeyFingerprint != null) {
2882
+ pinNote = "pinned";
3189
2883
  }
3190
- if (result.fingerprint != null) {
3191
- let pinNote = "not pinned \u2014 add it as hostKeyFingerprint to detect a changed key";
3192
- if (result.profile.hostKeyFingerprint != null) {
3193
- pinNote = "pinned";
3194
- }
3195
- process.stdout.write(` host key ${result.fingerprint} (${pinNote})
2884
+ process.stdout.write(` host key ${connection.hostKeyFingerprint} (${pinNote})
3196
2885
  `);
3197
- }
3198
2886
  }
3199
- process.stdout.write(`
3200
- ${connectedCount} of ${results.length} reachable
3201
- `);
3202
- if (connectedCount === results.length) {
2887
+ if (connected == true) {
3203
2888
  return 0;
3204
2889
  }
3205
2890
  return 1;
@@ -3220,9 +2905,9 @@ async function main() {
3220
2905
  if (cliArguments.checkOnly == true && process.env[LOG_LEVEL_ENV] == null) {
3221
2906
  process.env[LOG_LEVEL_ENV] = "silent";
3222
2907
  }
3223
- const instance = createSshMcpServer({ configPath: cliArguments.configPath });
2908
+ const instance = createSshMcpServer();
3224
2909
  if (cliArguments.checkOnly == true) {
3225
- const exitCode = await runConnectionCheck(instance.config);
2910
+ const exitCode = await runConnectionCheck(instance.profile);
3226
2911
  instance.shutdown();
3227
2912
  process.exit(exitCode);
3228
2913
  }
@@ -3252,8 +2937,7 @@ async function main() {
3252
2937
  _logger.info("mcp server ready on stdio", {
3253
2938
  server: SERVER_NAME,
3254
2939
  version: SERVER_VERSION,
3255
- config_source: instance.config.sourceLabel,
3256
- profile_count: instance.config.profiles.size
2940
+ profile: instance.profile?.name ?? null
3257
2941
  });
3258
2942
  }
3259
2943
  main().catch((error) => {
@@ -3263,7 +2947,7 @@ main().catch((error) => {
3263
2947
  if (error instanceof Error) {
3264
2948
  detail = error.message;
3265
2949
  }
3266
- process.stderr.write(`akms-ssh: ${detail}
2950
+ process.stderr.write(`akms-mcp-ssh: ${detail}
3267
2951
  `);
3268
2952
  process.exit(1);
3269
2953
  });