@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/README.md +191 -332
- package/lib/cli.cjs +208 -524
- package/lib/cli.cjs.map +1 -1
- package/lib/cli.js +207 -523
- package/lib/cli.js.map +1 -1
- package/lib/index.cjs +159 -448
- package/lib/index.cjs.map +1 -1
- package/lib/index.d.cts +70 -139
- package/lib/index.d.ts +70 -139
- package/lib/index.js +156 -442
- package/lib/index.js.map +1 -1
- package/package.json +61 -61
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
|
|
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
|
|
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.
|
|
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
|
|
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
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
|
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"
|
|
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(
|
|
2025
|
+
throw new Error(`${SINGLE_HOST_ENV.DENY_PATTERNS} has an invalid entry /${source}/: ${detail}`);
|
|
2134
2026
|
}
|
|
2135
2027
|
});
|
|
2136
2028
|
}
|
|
2137
|
-
function
|
|
2138
|
-
const
|
|
2139
|
-
if (
|
|
2140
|
-
|
|
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
|
|
2143
|
-
|
|
2144
|
-
|
|
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
|
-
|
|
2149
|
-
|
|
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
|
-
|
|
2152
|
-
readonly:
|
|
2153
|
-
allowSudo:
|
|
2154
|
-
execTimeoutMs:
|
|
2155
|
-
connectTimeoutMs:
|
|
2156
|
-
maxOutputCharacters:
|
|
2157
|
-
maxReadFileBytes:
|
|
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(
|
|
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
|
|
2179
|
-
port:
|
|
2180
|
-
username
|
|
2181
|
-
description:
|
|
2182
|
-
|
|
2183
|
-
|
|
2184
|
-
|
|
2185
|
-
|
|
2186
|
-
|
|
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
|
-
|
|
2190
|
-
|
|
2071
|
+
const keyPath = readEnv(SINGLE_HOST_ENV.KEY);
|
|
2072
|
+
if (keyPath != null) {
|
|
2073
|
+
const expandedKeyPath = expandHomePath(keyPath);
|
|
2191
2074
|
profile.privateKeyPath = expandedKeyPath;
|
|
2192
|
-
if (
|
|
2193
|
-
|
|
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
|
-
|
|
2200
|
-
|
|
2201
|
-
|
|
2202
|
-
|
|
2203
|
-
|
|
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
|
|
2087
|
+
return profile;
|
|
2272
2088
|
}
|
|
2273
2089
|
function toHostSummary(profile) {
|
|
2274
2090
|
let authMethod = "agent";
|
|
2275
|
-
if (profile.privateKeyPath != 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
|
|
2302
|
-
if (
|
|
2117
|
+
function formatHostProfile(profile) {
|
|
2118
|
+
if (profile == null) {
|
|
2303
2119
|
return [
|
|
2304
|
-
"No SSH host
|
|
2120
|
+
"No SSH host is registered.",
|
|
2305
2121
|
"",
|
|
2306
|
-
|
|
2122
|
+
"Put the connection in this server's MCP entry \u2014 one server entry per host:",
|
|
2307
2123
|
"",
|
|
2308
2124
|
` "env": {`,
|
|
2309
|
-
` "${
|
|
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
|
-
|
|
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(`${
|
|
2318
|
-
|
|
2319
|
-
|
|
2320
|
-
|
|
2321
|
-
|
|
2322
|
-
|
|
2323
|
-
|
|
2324
|
-
|
|
2325
|
-
|
|
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 (
|
|
2472
|
-
|
|
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
|
-
|
|
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
|
|
2519
|
-
"
|
|
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
|
|
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,
|
|
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, {
|
|
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
|
|
2577
|
-
|
|
2578
|
-
|
|
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
|
|
2591
|
-
"Cheaper and more structured than running 'ls -la' through ssh_exec, and it works on read-only
|
|
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.
|
|
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,
|
|
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, {
|
|
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
|
|
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,
|
|
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, {
|
|
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
|
|
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
|
|
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,
|
|
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, {
|
|
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
|
|
2728
|
-
"Refused on read-only
|
|
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,
|
|
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, {
|
|
2525
|
+
const { profile, session: openSession } = resolveProfile(context, { session });
|
|
2748
2526
|
const resolvedLocalPath = expandHomePath(localPath);
|
|
2749
|
-
requirePermittedLocalPath(
|
|
2750
|
-
if (
|
|
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
|
|
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
|
|
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,
|
|
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, {
|
|
2565
|
+
const { profile, session: openSession } = resolveProfile(context, { session });
|
|
2789
2566
|
const resolvedLocalPath = expandHomePath(localPath);
|
|
2790
|
-
requirePermittedLocalPath(
|
|
2791
|
-
|
|
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: "
|
|
2586
|
+
title: "Show the SSH host",
|
|
2810
2587
|
description: [
|
|
2811
|
-
"
|
|
2812
|
-
"Call this first:
|
|
2813
|
-
"Credentials are never returned \u2014 only which auth method the
|
|
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 =
|
|
2827
|
-
_logger.debug("host
|
|
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", "
|
|
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
|
|
2890
|
-
"Prefer this over one-off
|
|
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
|
-
|
|
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 ({
|
|
2905
|
-
let _logger = $logger.child({ context: "ssh_connect"
|
|
2632
|
+
async ({ cwd }) => {
|
|
2633
|
+
let _logger = $logger.child({ context: "ssh_connect" });
|
|
2906
2634
|
let openedSessionId = null;
|
|
2907
2635
|
try {
|
|
2908
|
-
const { profile } = resolveProfile(context, {
|
|
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
|
|
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",
|
|
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
|
|
3011
|
-
"
|
|
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
|
-
"
|
|
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
|
|
3023
|
-
"
|
|
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:
|
|
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(
|
|
2759
|
+
function createSshMcpServer() {
|
|
3033
2760
|
const _logger = $logger.child({ context: "createSshMcpServer" });
|
|
3034
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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 [--
|
|
2813
|
+
" akms-mcp-ssh [--check]",
|
|
3118
2814
|
"",
|
|
3119
2815
|
"Options:",
|
|
3120
|
-
"
|
|
3121
|
-
|
|
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
|
-
` ${
|
|
3129
|
-
|
|
3130
|
-
|
|
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(
|
|
3148
|
-
if (
|
|
3149
|
-
process.stdout.write(`No host
|
|
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
|
-
|
|
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
|
|
3157
|
-
const
|
|
3158
|
-
|
|
3159
|
-
|
|
3160
|
-
|
|
3161
|
-
|
|
3162
|
-
|
|
3163
|
-
|
|
3164
|
-
|
|
3165
|
-
|
|
3166
|
-
|
|
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
|
-
|
|
3186
|
-
|
|
3187
|
-
|
|
3188
|
-
|
|
3189
|
-
|
|
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
|
-
|
|
3194
|
-
|
|
2882
|
+
} else {
|
|
2883
|
+
process.stdout.write(` FAILED ${endpoint} (${auth})
|
|
3195
2884
|
`);
|
|
3196
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
2917
|
+
const instance = createSshMcpServer();
|
|
3233
2918
|
if (cliArguments.checkOnly == true) {
|
|
3234
|
-
const exitCode = await runConnectionCheck(instance.
|
|
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
|
-
|
|
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
|
});
|