@akms/mcp-ssh 0.0.1 → 0.0.3
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/LICENSE.md +11 -11
- 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 +10 -2
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
|
|
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.
|
|
25
|
+
var SERVER_NAME = "akms-mcp-ssh";
|
|
26
|
+
var SERVER_VERSION = "0.0.3";
|
|
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
|
|
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
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
|
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"
|
|
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(
|
|
2016
|
+
throw new Error(`${SINGLE_HOST_ENV.DENY_PATTERNS} has an invalid entry /${source}/: ${detail}`);
|
|
2125
2017
|
}
|
|
2126
2018
|
});
|
|
2127
2019
|
}
|
|
2128
|
-
function
|
|
2129
|
-
const
|
|
2130
|
-
if (
|
|
2131
|
-
|
|
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
|
|
2134
|
-
|
|
2135
|
-
|
|
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
|
-
|
|
2140
|
-
|
|
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
|
-
|
|
2143
|
-
readonly:
|
|
2144
|
-
allowSudo:
|
|
2145
|
-
execTimeoutMs:
|
|
2146
|
-
connectTimeoutMs:
|
|
2147
|
-
maxOutputCharacters:
|
|
2148
|
-
maxReadFileBytes:
|
|
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(
|
|
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
|
|
2170
|
-
port:
|
|
2171
|
-
username
|
|
2172
|
-
description:
|
|
2173
|
-
|
|
2174
|
-
|
|
2175
|
-
|
|
2176
|
-
|
|
2177
|
-
|
|
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
|
-
|
|
2181
|
-
|
|
2062
|
+
const keyPath = readEnv(SINGLE_HOST_ENV.KEY);
|
|
2063
|
+
if (keyPath != null) {
|
|
2064
|
+
const expandedKeyPath = expandHomePath(keyPath);
|
|
2182
2065
|
profile.privateKeyPath = expandedKeyPath;
|
|
2183
|
-
if (
|
|
2184
|
-
|
|
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
|
-
|
|
2191
|
-
|
|
2192
|
-
|
|
2193
|
-
|
|
2194
|
-
|
|
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
|
|
2078
|
+
return profile;
|
|
2263
2079
|
}
|
|
2264
2080
|
function toHostSummary(profile) {
|
|
2265
2081
|
let authMethod = "agent";
|
|
2266
|
-
if (profile.privateKeyPath != 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
|
|
2293
|
-
if (
|
|
2108
|
+
function formatHostProfile(profile) {
|
|
2109
|
+
if (profile == null) {
|
|
2294
2110
|
return [
|
|
2295
|
-
"No SSH host
|
|
2111
|
+
"No SSH host is registered.",
|
|
2296
2112
|
"",
|
|
2297
|
-
|
|
2113
|
+
"Put the connection in this server's MCP entry \u2014 one server entry per host:",
|
|
2298
2114
|
"",
|
|
2299
2115
|
` "env": {`,
|
|
2300
|
-
` "${
|
|
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
|
-
|
|
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(`${
|
|
2309
|
-
|
|
2310
|
-
|
|
2311
|
-
|
|
2312
|
-
|
|
2313
|
-
|
|
2314
|
-
|
|
2315
|
-
|
|
2316
|
-
|
|
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 (
|
|
2463
|
-
|
|
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
|
-
|
|
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
|
|
2510
|
-
"
|
|
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
|
|
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,
|
|
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, {
|
|
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
|
|
2568
|
-
|
|
2569
|
-
|
|
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
|
|
2582
|
-
"Cheaper and more structured than running 'ls -la' through ssh_exec, and it works on read-only
|
|
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.
|
|
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,
|
|
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, {
|
|
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
|
|
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,
|
|
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, {
|
|
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
|
|
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
|
|
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,
|
|
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, {
|
|
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
|
|
2719
|
-
"Refused on read-only
|
|
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,
|
|
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, {
|
|
2516
|
+
const { profile, session: openSession } = resolveProfile(context, { session });
|
|
2739
2517
|
const resolvedLocalPath = expandHomePath(localPath);
|
|
2740
|
-
requirePermittedLocalPath(
|
|
2741
|
-
if (
|
|
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
|
|
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
|
|
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,
|
|
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, {
|
|
2556
|
+
const { profile, session: openSession } = resolveProfile(context, { session });
|
|
2780
2557
|
const resolvedLocalPath = expandHomePath(localPath);
|
|
2781
|
-
requirePermittedLocalPath(
|
|
2782
|
-
|
|
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: "
|
|
2577
|
+
title: "Show the SSH host",
|
|
2801
2578
|
description: [
|
|
2802
|
-
"
|
|
2803
|
-
"Call this first:
|
|
2804
|
-
"Credentials are never returned \u2014 only which auth method the
|
|
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 =
|
|
2818
|
-
_logger.debug("host
|
|
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", "
|
|
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
|
|
2881
|
-
"Prefer this over one-off
|
|
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
|
-
|
|
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 ({
|
|
2896
|
-
let _logger = $logger.child({ context: "ssh_connect"
|
|
2623
|
+
async ({ cwd }) => {
|
|
2624
|
+
let _logger = $logger.child({ context: "ssh_connect" });
|
|
2897
2625
|
let openedSessionId = null;
|
|
2898
2626
|
try {
|
|
2899
|
-
const { profile } = resolveProfile(context, {
|
|
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
|
|
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",
|
|
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
|
|
3002
|
-
"
|
|
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
|
-
"
|
|
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
|
|
3014
|
-
"
|
|
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:
|
|
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(
|
|
2750
|
+
function createSshMcpServer() {
|
|
3024
2751
|
const _logger = $logger.child({ context: "createSshMcpServer" });
|
|
3025
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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 [--
|
|
2804
|
+
" akms-mcp-ssh [--check]",
|
|
3109
2805
|
"",
|
|
3110
2806
|
"Options:",
|
|
3111
|
-
"
|
|
3112
|
-
|
|
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
|
-
` ${
|
|
3120
|
-
|
|
3121
|
-
|
|
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(
|
|
3139
|
-
if (
|
|
3140
|
-
process.stdout.write(`No host
|
|
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
|
-
|
|
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
|
|
3148
|
-
const
|
|
3149
|
-
|
|
3150
|
-
|
|
3151
|
-
|
|
3152
|
-
|
|
3153
|
-
|
|
3154
|
-
|
|
3155
|
-
|
|
3156
|
-
|
|
3157
|
-
|
|
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
|
-
|
|
3177
|
-
|
|
3178
|
-
|
|
3179
|
-
|
|
3180
|
-
|
|
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
|
-
|
|
3185
|
-
|
|
2873
|
+
} else {
|
|
2874
|
+
process.stdout.write(` FAILED ${endpoint} (${auth})
|
|
3186
2875
|
`);
|
|
3187
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
2908
|
+
const instance = createSshMcpServer();
|
|
3224
2909
|
if (cliArguments.checkOnly == true) {
|
|
3225
|
-
const exitCode = await runConnectionCheck(instance.
|
|
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
|
-
|
|
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
|
});
|