@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/index.cjs
CHANGED
|
@@ -2,30 +2,25 @@
|
|
|
2
2
|
|
|
3
3
|
var mcp_js = require('@modelcontextprotocol/sdk/server/mcp.js');
|
|
4
4
|
var os = require('os');
|
|
5
|
-
var
|
|
6
|
-
var
|
|
7
|
-
var zod = require('zod');
|
|
5
|
+
var path2 = require('path');
|
|
6
|
+
var fs2 = require('fs');
|
|
8
7
|
var crypto = require('crypto');
|
|
9
8
|
var string_decoder = require('string_decoder');
|
|
10
9
|
var ssh2 = require('ssh2');
|
|
11
10
|
var timers = require('timers');
|
|
11
|
+
var zod = require('zod');
|
|
12
12
|
|
|
13
13
|
function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
|
|
14
14
|
|
|
15
15
|
var os__default = /*#__PURE__*/_interopDefault(os);
|
|
16
|
-
var
|
|
17
|
-
var
|
|
16
|
+
var path2__default = /*#__PURE__*/_interopDefault(path2);
|
|
17
|
+
var fs2__default = /*#__PURE__*/_interopDefault(fs2);
|
|
18
18
|
var ssh2__default = /*#__PURE__*/_interopDefault(ssh2);
|
|
19
19
|
|
|
20
20
|
// src/server.ts
|
|
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.3";
|
|
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",
|
|
@@ -594,15 +585,15 @@ function expandHomePath(inputPath) {
|
|
|
594
585
|
if (inputPath === "~") {
|
|
595
586
|
expanded = os__default.default.homedir();
|
|
596
587
|
} else if (inputPath.startsWith("~/") == true || inputPath.startsWith("~\\") == true) {
|
|
597
|
-
expanded =
|
|
588
|
+
expanded = path2__default.default.join(os__default.default.homedir(), inputPath.slice(2));
|
|
598
589
|
}
|
|
599
|
-
return
|
|
590
|
+
return path2__default.default.resolve(expanded);
|
|
600
591
|
}
|
|
601
592
|
function isAbsoluteRemotePath(remotePath) {
|
|
602
593
|
return remotePath.startsWith("/");
|
|
603
594
|
}
|
|
604
595
|
function normalizeRemotePath(remotePath) {
|
|
605
|
-
return
|
|
596
|
+
return path2__default.default.posix.normalize(remotePath);
|
|
606
597
|
}
|
|
607
598
|
function isRemotePathWithin(candidate, allowedPrefix) {
|
|
608
599
|
const normalizedCandidate = normalizeRemotePath(candidate);
|
|
@@ -615,43 +606,6 @@ function isRemotePathWithin(candidate, allowedPrefix) {
|
|
|
615
606
|
}
|
|
616
607
|
return normalizedCandidate.startsWith(`${normalizedPrefix}/`);
|
|
617
608
|
}
|
|
618
|
-
var POLICY_INPUT_SCHEMA = zod.z.strictObject({
|
|
619
|
-
readonly: zod.z.boolean().optional(),
|
|
620
|
-
allowSudo: zod.z.boolean().optional(),
|
|
621
|
-
execTimeoutMs: zod.z.number().int().positive().optional(),
|
|
622
|
-
connectTimeoutMs: zod.z.number().int().positive().optional(),
|
|
623
|
-
maxOutputCharacters: zod.z.number().int().positive().optional(),
|
|
624
|
-
maxReadFileBytes: zod.z.number().int().positive().optional(),
|
|
625
|
-
allowCommands: zod.z.array(zod.z.string()).optional(),
|
|
626
|
-
denyPatterns: zod.z.array(zod.z.string()).optional(),
|
|
627
|
-
allowedPaths: zod.z.array(zod.z.string()).optional()
|
|
628
|
-
});
|
|
629
|
-
var HOST_INPUT_SCHEMA = POLICY_INPUT_SCHEMA.extend({
|
|
630
|
-
// Kept strict by `.extend()` on a strict base — verified by the config tests.
|
|
631
|
-
host: zod.z.string().min(1),
|
|
632
|
-
port: zod.z.number().int().min(1).max(65535).optional(),
|
|
633
|
-
username: zod.z.string().min(1),
|
|
634
|
-
description: zod.z.string().optional(),
|
|
635
|
-
privateKeyPath: zod.z.string().optional(),
|
|
636
|
-
privateKey: zod.z.string().optional(),
|
|
637
|
-
passphrase: zod.z.string().optional(),
|
|
638
|
-
/** Name of an env var holding the passphrase, preferred over inlining it. */
|
|
639
|
-
passphraseEnv: zod.z.string().optional(),
|
|
640
|
-
password: zod.z.string().optional(),
|
|
641
|
-
/** Name of an env var holding the password, preferred over inlining it. */
|
|
642
|
-
passwordEnv: zod.z.string().optional(),
|
|
643
|
-
/** Pinned server host key, e.g. "SHA256:abc123…". */
|
|
644
|
-
hostKeyFingerprint: zod.z.string().optional(),
|
|
645
|
-
defaultCwd: zod.z.string().optional()
|
|
646
|
-
});
|
|
647
|
-
var CONFIG_INPUT_SCHEMA = zod.z.strictObject({
|
|
648
|
-
/** Ignored at runtime; present so editors can attach a JSON schema. */
|
|
649
|
-
$schema: zod.z.string().optional(),
|
|
650
|
-
defaults: POLICY_INPUT_SCHEMA.optional(),
|
|
651
|
-
hosts: zod.z.record(zod.z.string(), HOST_INPUT_SCHEMA)
|
|
652
|
-
});
|
|
653
|
-
|
|
654
|
-
// src/modules/config/env-host.ts
|
|
655
609
|
function readEnv(name) {
|
|
656
610
|
const value = process.env[name];
|
|
657
611
|
if (value == null) {
|
|
@@ -699,220 +653,84 @@ function readListEnv(name) {
|
|
|
699
653
|
}
|
|
700
654
|
return items;
|
|
701
655
|
}
|
|
702
|
-
function
|
|
703
|
-
const host = readEnv(SINGLE_HOST_ENV.HOST);
|
|
704
|
-
if (host == null) {
|
|
705
|
-
return null;
|
|
706
|
-
}
|
|
707
|
-
const username = readEnv(SINGLE_HOST_ENV.USER);
|
|
708
|
-
if (username == null) {
|
|
709
|
-
throw new Error(`${SINGLE_HOST_ENV.HOST} is set, so ${SINGLE_HOST_ENV.USER} is required`);
|
|
710
|
-
}
|
|
711
|
-
const hostInput = {
|
|
712
|
-
host,
|
|
713
|
-
username,
|
|
714
|
-
port: readIntegerEnv(SINGLE_HOST_ENV.PORT),
|
|
715
|
-
description: readEnv(SINGLE_HOST_ENV.DESCRIPTION),
|
|
716
|
-
privateKeyPath: readEnv(SINGLE_HOST_ENV.KEY),
|
|
717
|
-
passphrase: readEnv(SINGLE_HOST_ENV.PASSPHRASE),
|
|
718
|
-
password: readEnv(SINGLE_HOST_ENV.PASSWORD),
|
|
719
|
-
hostKeyFingerprint: readEnv(SINGLE_HOST_ENV.HOST_KEY),
|
|
720
|
-
defaultCwd: readEnv(SINGLE_HOST_ENV.CWD),
|
|
721
|
-
readonly: readBooleanEnv(SINGLE_HOST_ENV.READONLY),
|
|
722
|
-
allowSudo: readBooleanEnv(SINGLE_HOST_ENV.ALLOW_SUDO),
|
|
723
|
-
allowCommands: readListEnv(SINGLE_HOST_ENV.ALLOW_COMMANDS),
|
|
724
|
-
denyPatterns: readListEnv(SINGLE_HOST_ENV.DENY_PATTERNS),
|
|
725
|
-
allowedPaths: readListEnv(SINGLE_HOST_ENV.ALLOWED_PATHS),
|
|
726
|
-
execTimeoutMs: readIntegerEnv(SINGLE_HOST_ENV.EXEC_TIMEOUT_MS),
|
|
727
|
-
connectTimeoutMs: readIntegerEnv(SINGLE_HOST_ENV.CONNECT_TIMEOUT_MS),
|
|
728
|
-
maxOutputCharacters: readIntegerEnv(SINGLE_HOST_ENV.MAX_OUTPUT_CHARACTERS),
|
|
729
|
-
maxReadFileBytes: readIntegerEnv(SINGLE_HOST_ENV.MAX_READ_FILE_BYTES)
|
|
730
|
-
};
|
|
731
|
-
const name = readEnv(SINGLE_HOST_ENV.NAME) ?? host;
|
|
732
|
-
const _logger = $logger.child({ context: "buildConfigFromEnv", profile: name });
|
|
733
|
-
_logger.debug("single-host config assembled from environment variables");
|
|
734
|
-
return { hosts: { [name]: hostInput } };
|
|
735
|
-
}
|
|
736
|
-
|
|
737
|
-
// src/modules/config/load-config.ts
|
|
738
|
-
function resolveConfigSource(explicitPath) {
|
|
739
|
-
if (explicitPath != null && explicitPath.trim() !== "") {
|
|
740
|
-
return { kind: "file", path: expandHomePath(explicitPath) };
|
|
741
|
-
}
|
|
742
|
-
const fromSingleHostEnv = buildConfigFromEnv();
|
|
743
|
-
if (fromSingleHostEnv != null) {
|
|
744
|
-
return { kind: "env", config: fromSingleHostEnv };
|
|
745
|
-
}
|
|
746
|
-
const fromEnv = process.env[CONFIG_PATH_ENV];
|
|
747
|
-
if (fromEnv != null && fromEnv.trim() !== "") {
|
|
748
|
-
const trimmedValue = fromEnv.trim();
|
|
749
|
-
if (trimmedValue.startsWith("{") == true) {
|
|
750
|
-
return { kind: "inline", json: trimmedValue };
|
|
751
|
-
}
|
|
752
|
-
return { kind: "file", path: expandHomePath(trimmedValue) };
|
|
753
|
-
}
|
|
754
|
-
for (const filename of DEFAULT_CONFIG_FILENAMES) {
|
|
755
|
-
const candidate = path3__default.default.join(os__default.default.homedir(), filename);
|
|
756
|
-
if (fs__default.default.existsSync(candidate) == true) {
|
|
757
|
-
return { kind: "file", path: candidate };
|
|
758
|
-
}
|
|
759
|
-
}
|
|
760
|
-
return { kind: "none" };
|
|
761
|
-
}
|
|
762
|
-
function compileDenyPatterns(profileName, sources) {
|
|
656
|
+
function compileDenyPatterns(sources) {
|
|
763
657
|
return sources.map((source) => {
|
|
764
658
|
try {
|
|
765
659
|
return new RegExp(source, "i");
|
|
766
660
|
} catch (ex) {
|
|
767
|
-
const _logger = $logger.child({ context: "compileDenyPatterns"
|
|
661
|
+
const _logger = $logger.child({ context: "compileDenyPatterns" });
|
|
768
662
|
_logger.error(ex, "denyPatterns entry is not a valid regular expression");
|
|
769
663
|
let detail = String(ex);
|
|
770
664
|
if (ex instanceof Error) {
|
|
771
665
|
detail = ex.message;
|
|
772
666
|
}
|
|
773
|
-
throw new Error(
|
|
667
|
+
throw new Error(`${SINGLE_HOST_ENV.DENY_PATTERNS} has an invalid entry /${source}/: ${detail}`);
|
|
774
668
|
}
|
|
775
669
|
});
|
|
776
670
|
}
|
|
777
|
-
function
|
|
778
|
-
const
|
|
779
|
-
if (
|
|
780
|
-
|
|
671
|
+
function loadHostProfile() {
|
|
672
|
+
const host = readEnv(SINGLE_HOST_ENV.HOST);
|
|
673
|
+
if (host == null) {
|
|
674
|
+
const _startupLogger = $logger.child({ context: "loadHostProfile" });
|
|
675
|
+
_startupLogger.warn("no host is registered; set the connection variables and restart", {
|
|
676
|
+
env_vars: [SINGLE_HOST_ENV.HOST, SINGLE_HOST_ENV.USER]
|
|
677
|
+
});
|
|
678
|
+
return null;
|
|
781
679
|
}
|
|
782
|
-
const
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
if (override.allowCommands?.length === 0 && (defaults?.allowCommands?.length ?? 0) > 0) {
|
|
786
|
-
_logger.warn("host sets allowCommands to an empty list, which removes the restriction from defaults");
|
|
680
|
+
const username = readEnv(SINGLE_HOST_ENV.USER);
|
|
681
|
+
if (username == null) {
|
|
682
|
+
throw new Error(`${SINGLE_HOST_ENV.HOST} is set, so ${SINGLE_HOST_ENV.USER} is required`);
|
|
787
683
|
}
|
|
788
|
-
|
|
789
|
-
|
|
684
|
+
const name = readEnv(SINGLE_HOST_ENV.NAME) ?? host;
|
|
685
|
+
const _logger = $logger.child({ context: "loadHostProfile", profile: name });
|
|
686
|
+
const allowCommands = readListEnv(SINGLE_HOST_ENV.ALLOW_COMMANDS) ?? [];
|
|
687
|
+
if (allowCommands.length > 0 && allowCommands.includes("pwd") == false) {
|
|
688
|
+
allowCommands.push("pwd");
|
|
790
689
|
}
|
|
791
|
-
|
|
792
|
-
readonly:
|
|
793
|
-
allowSudo:
|
|
794
|
-
execTimeoutMs:
|
|
795
|
-
connectTimeoutMs:
|
|
796
|
-
maxOutputCharacters:
|
|
797
|
-
maxReadFileBytes:
|
|
690
|
+
const policy = {
|
|
691
|
+
readonly: readBooleanEnv(SINGLE_HOST_ENV.READONLY) ?? false,
|
|
692
|
+
allowSudo: readBooleanEnv(SINGLE_HOST_ENV.ALLOW_SUDO) ?? true,
|
|
693
|
+
execTimeoutMs: readIntegerEnv(SINGLE_HOST_ENV.EXEC_TIMEOUT_MS) ?? DEFAULT_EXEC_TIMEOUT_MS,
|
|
694
|
+
connectTimeoutMs: readIntegerEnv(SINGLE_HOST_ENV.CONNECT_TIMEOUT_MS) ?? DEFAULT_CONNECT_TIMEOUT_MS,
|
|
695
|
+
maxOutputCharacters: readIntegerEnv(SINGLE_HOST_ENV.MAX_OUTPUT_CHARACTERS) ?? DEFAULT_MAX_OUTPUT_CHARACTERS,
|
|
696
|
+
maxReadFileBytes: readIntegerEnv(SINGLE_HOST_ENV.MAX_READ_FILE_BYTES) ?? DEFAULT_MAX_READ_FILE_BYTES,
|
|
798
697
|
allowCommands,
|
|
799
|
-
denyPatterns: compileDenyPatterns(
|
|
800
|
-
allowedPaths
|
|
698
|
+
denyPatterns: compileDenyPatterns(readListEnv(SINGLE_HOST_ENV.DENY_PATTERNS) ?? []),
|
|
699
|
+
allowedPaths: readListEnv(SINGLE_HOST_ENV.ALLOWED_PATHS) ?? []
|
|
801
700
|
};
|
|
802
|
-
}
|
|
803
|
-
function resolveSecret(profileName, inlineValue, envVarName) {
|
|
804
|
-
if (envVarName == null) {
|
|
805
|
-
return inlineValue;
|
|
806
|
-
}
|
|
807
|
-
const fromEnv = process.env[envVarName];
|
|
808
|
-
if (fromEnv == null || fromEnv === "") {
|
|
809
|
-
const _logger = $logger.child({ context: "resolveSecret", profile: profileName, env_var: envVarName });
|
|
810
|
-
_logger.warn("env var referenced by the profile is empty; falling back to the inline value");
|
|
811
|
-
return inlineValue;
|
|
812
|
-
}
|
|
813
|
-
return fromEnv;
|
|
814
|
-
}
|
|
815
|
-
function toHostProfile(name, raw, defaults) {
|
|
816
701
|
const profile = {
|
|
817
702
|
name,
|
|
818
|
-
host
|
|
819
|
-
port:
|
|
820
|
-
username
|
|
821
|
-
description:
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
policy: resolvePolicy(name, defaults, raw)
|
|
703
|
+
host,
|
|
704
|
+
port: readIntegerEnv(SINGLE_HOST_ENV.PORT) ?? DEFAULT_SSH_PORT,
|
|
705
|
+
username,
|
|
706
|
+
description: readEnv(SINGLE_HOST_ENV.DESCRIPTION),
|
|
707
|
+
passphrase: readEnv(SINGLE_HOST_ENV.PASSPHRASE),
|
|
708
|
+
password: readEnv(SINGLE_HOST_ENV.PASSWORD),
|
|
709
|
+
hostKeyFingerprint: readEnv(SINGLE_HOST_ENV.HOST_KEY),
|
|
710
|
+
defaultCwd: readEnv(SINGLE_HOST_ENV.CWD),
|
|
711
|
+
policy
|
|
828
712
|
};
|
|
829
|
-
|
|
830
|
-
|
|
713
|
+
const keyPath = readEnv(SINGLE_HOST_ENV.KEY);
|
|
714
|
+
if (keyPath != null) {
|
|
715
|
+
const expandedKeyPath = expandHomePath(keyPath);
|
|
831
716
|
profile.privateKeyPath = expandedKeyPath;
|
|
832
|
-
if (
|
|
833
|
-
|
|
834
|
-
_logger.warn("privateKeyPath does not exist; connections for this profile will fail", {
|
|
717
|
+
if (fs2__default.default.existsSync(expandedKeyPath) == false) {
|
|
718
|
+
_logger.warn(`${SINGLE_HOST_ENV.KEY} does not exist; connections will fail`, {
|
|
835
719
|
private_key_path: expandedKeyPath
|
|
836
720
|
});
|
|
837
721
|
}
|
|
838
722
|
}
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
}
|
|
845
|
-
const stats = fs__default.default.statSync(configPath);
|
|
846
|
-
const permissionBits = stats.mode & 511;
|
|
847
|
-
if ((permissionBits & 63) === 0) {
|
|
848
|
-
return;
|
|
849
|
-
}
|
|
850
|
-
const _logger = $logger.child({ context: "warnOnLoosePermissions", config_path: configPath });
|
|
851
|
-
_logger.warn(`config file is readable by other users, run: chmod 600 ${configPath}`, {
|
|
852
|
-
mode: permissionBits.toString(8)
|
|
853
|
-
});
|
|
854
|
-
}
|
|
855
|
-
function loadConfig(explicitPath) {
|
|
856
|
-
let _logger = $logger.child({ context: "loadConfig" });
|
|
857
|
-
const source = resolveConfigSource(explicitPath);
|
|
858
|
-
if (source.kind === "none") {
|
|
859
|
-
_logger.warn("no config found; no host profiles are registered", {
|
|
860
|
-
searched: DEFAULT_CONFIG_FILENAMES,
|
|
861
|
-
env_vars: [SINGLE_HOST_ENV.HOST, CONFIG_PATH_ENV]
|
|
862
|
-
});
|
|
863
|
-
return { profiles: /* @__PURE__ */ new Map(), sourcePath: null, sourceLabel: "none" };
|
|
864
|
-
}
|
|
865
|
-
let parsedJson = null;
|
|
866
|
-
let sourcePath = null;
|
|
867
|
-
let sourceLabel = `inline ${CONFIG_PATH_ENV}`;
|
|
868
|
-
if (source.kind === "env") {
|
|
869
|
-
_logger = _logger.child({ config_source: "env" });
|
|
870
|
-
sourceLabel = `${SINGLE_HOST_ENV.HOST} environment variables`;
|
|
871
|
-
parsedJson = source.config;
|
|
872
|
-
} else {
|
|
873
|
-
let rawText = "";
|
|
874
|
-
if (source.kind === "inline") {
|
|
875
|
-
_logger = _logger.child({ config_source: "inline" });
|
|
876
|
-
rawText = source.json;
|
|
877
|
-
} else {
|
|
878
|
-
_logger = _logger.child({ config_path: source.path });
|
|
879
|
-
sourcePath = source.path;
|
|
880
|
-
sourceLabel = source.path;
|
|
881
|
-
if (fs__default.default.existsSync(source.path) == false) {
|
|
882
|
-
_logger.error(null, "config file does not exist");
|
|
883
|
-
throw new Error(`ssh mcp config not found: ${source.path}`);
|
|
884
|
-
}
|
|
885
|
-
rawText = fs__default.default.readFileSync(source.path, "utf8");
|
|
886
|
-
}
|
|
887
|
-
try {
|
|
888
|
-
parsedJson = JSON.parse(rawText);
|
|
889
|
-
} catch (ex) {
|
|
890
|
-
_logger.error(ex, "config is not valid json");
|
|
891
|
-
throw new Error(`ssh mcp config is not valid JSON (${sourceLabel})`);
|
|
892
|
-
}
|
|
893
|
-
}
|
|
894
|
-
const validated = CONFIG_INPUT_SCHEMA.safeParse(parsedJson);
|
|
895
|
-
if (validated.success == false) {
|
|
896
|
-
const issueSummary = validated.error.issues.map((issue) => `${issue.path.join(".")}: ${issue.message}`).join("; ");
|
|
897
|
-
_logger.error(validated.error, "config failed schema validation");
|
|
898
|
-
throw new Error(`ssh mcp config is invalid (${sourceLabel}): ${issueSummary}`);
|
|
899
|
-
}
|
|
900
|
-
if (sourcePath != null) {
|
|
901
|
-
warnOnLoosePermissions(sourcePath);
|
|
902
|
-
}
|
|
903
|
-
const profiles = /* @__PURE__ */ new Map();
|
|
904
|
-
for (const [name, hostInput] of Object.entries(validated.data.hosts)) {
|
|
905
|
-
profiles.set(name, toHostProfile(name, hostInput, validated.data.defaults));
|
|
906
|
-
}
|
|
907
|
-
_logger.info("config loaded", {
|
|
908
|
-
profile_count: profiles.size,
|
|
909
|
-
profiles: Array.from(profiles.keys())
|
|
723
|
+
_logger.info("host profile loaded", {
|
|
724
|
+
host,
|
|
725
|
+
port: profile.port,
|
|
726
|
+
username,
|
|
727
|
+
readonly: policy.readonly
|
|
910
728
|
});
|
|
911
|
-
return
|
|
729
|
+
return profile;
|
|
912
730
|
}
|
|
913
731
|
function toHostSummary(profile) {
|
|
914
732
|
let authMethod = "agent";
|
|
915
|
-
if (profile.privateKeyPath != null
|
|
733
|
+
if (profile.privateKeyPath != null) {
|
|
916
734
|
authMethod = "privateKey";
|
|
917
735
|
} else if (profile.password != null) {
|
|
918
736
|
authMethod = "password";
|
|
@@ -1367,20 +1185,20 @@ function inspectRemotePath(remotePath, policy, access) {
|
|
|
1367
1185
|
function toRealPath(inputPath) {
|
|
1368
1186
|
const _logger = $logger.child({ context: "toRealPath" });
|
|
1369
1187
|
try {
|
|
1370
|
-
return
|
|
1188
|
+
return fs2__default.default.realpathSync.native(inputPath);
|
|
1371
1189
|
} catch (ex) {
|
|
1372
1190
|
_logger.debug("path does not resolve, falling back to its parent directory", {
|
|
1373
1191
|
reason: ex instanceof Error ? ex.name : "unknown"
|
|
1374
1192
|
});
|
|
1375
1193
|
}
|
|
1376
1194
|
try {
|
|
1377
|
-
const parentRealPath =
|
|
1378
|
-
return
|
|
1195
|
+
const parentRealPath = fs2__default.default.realpathSync.native(path2__default.default.dirname(inputPath));
|
|
1196
|
+
return path2__default.default.join(parentRealPath, path2__default.default.basename(inputPath));
|
|
1379
1197
|
} catch (ex) {
|
|
1380
1198
|
_logger.debug("parent directory does not resolve either, comparing lexically", {
|
|
1381
1199
|
reason: ex instanceof Error ? ex.name : "unknown"
|
|
1382
1200
|
});
|
|
1383
|
-
return
|
|
1201
|
+
return path2__default.default.resolve(inputPath);
|
|
1384
1202
|
}
|
|
1385
1203
|
}
|
|
1386
1204
|
function toComparablePath(inputPath) {
|
|
@@ -1466,7 +1284,7 @@ function readPrivateKey(profile, keyPath) {
|
|
|
1466
1284
|
return cached;
|
|
1467
1285
|
}
|
|
1468
1286
|
try {
|
|
1469
|
-
const contents =
|
|
1287
|
+
const contents = fs2__default.default.readFileSync(keyPath);
|
|
1470
1288
|
PRIVATE_KEY_CACHE.set(keyPath, contents);
|
|
1471
1289
|
return contents;
|
|
1472
1290
|
} catch (ex) {
|
|
@@ -1512,8 +1330,6 @@ function buildConnectConfig(profile, onHostKeySeen) {
|
|
|
1512
1330
|
};
|
|
1513
1331
|
if (profile.privateKeyPath != null) {
|
|
1514
1332
|
connectConfig.privateKey = readPrivateKey(profile, profile.privateKeyPath);
|
|
1515
|
-
} else if (profile.privateKey != null) {
|
|
1516
|
-
connectConfig.privateKey = profile.privateKey;
|
|
1517
1333
|
}
|
|
1518
1334
|
if (profile.passphrase != null) {
|
|
1519
1335
|
connectConfig.passphrase = profile.passphrase;
|
|
@@ -1530,7 +1346,7 @@ function buildRemoteCommand(command, cwd, trackCwd) {
|
|
|
1530
1346
|
const lines = [];
|
|
1531
1347
|
if (cwd != null && cwd !== "") {
|
|
1532
1348
|
const quotedCwd = quoteShellArgument(cwd);
|
|
1533
|
-
lines.push(`cd ${quotedCwd} 2>/dev/null || { echo 'akms-ssh: cannot enter the requested working directory' >&2; exit 1; }`);
|
|
1349
|
+
lines.push(`cd ${quotedCwd} 2>/dev/null || { echo 'akms-mcp-ssh: cannot enter the requested working directory' >&2; exit 1; }`);
|
|
1534
1350
|
}
|
|
1535
1351
|
lines.push(command);
|
|
1536
1352
|
if (trackCwd == true) {
|
|
@@ -2243,7 +2059,7 @@ async function writeRemoteFile(connection, args) {
|
|
|
2243
2059
|
}
|
|
2244
2060
|
async function uploadFile(connection, localPath, remotePath) {
|
|
2245
2061
|
requirePermittedPath(connection, remotePath, "write");
|
|
2246
|
-
const localStats =
|
|
2062
|
+
const localStats = fs2__default.default.statSync(localPath);
|
|
2247
2063
|
if (localStats.isFile() == false) {
|
|
2248
2064
|
throw new Error(`local path is not a file: ${localPath}`);
|
|
2249
2065
|
}
|
|
@@ -2280,7 +2096,7 @@ async function downloadFile(connection, remotePath, localPath) {
|
|
|
2280
2096
|
});
|
|
2281
2097
|
});
|
|
2282
2098
|
});
|
|
2283
|
-
const localStats =
|
|
2099
|
+
const localStats = fs2__default.default.statSync(localPath);
|
|
2284
2100
|
const _logger = $logger.child({
|
|
2285
2101
|
context: "downloadFile",
|
|
2286
2102
|
profile: connection.profile.name,
|
|
@@ -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 };
|
|
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`);
|
|
2478
2272
|
}
|
|
2479
|
-
|
|
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`);
|
|
2481
|
-
}
|
|
2482
|
-
const available = Array.from(context.config.profiles.keys());
|
|
2483
|
-
throw new Error(`this server fronts several hosts, so 'target' is required (registered: ${available.join(", ")})`);
|
|
2273
|
+
return { profile: context.profile, session: null };
|
|
2484
2274
|
}
|
|
2485
2275
|
async function withHostConnection(profile, session, operation) {
|
|
2486
2276
|
if (session != null) {
|
|
@@ -2501,12 +2291,6 @@ async function withHostConnection(profile, session, operation) {
|
|
|
2501
2291
|
}
|
|
2502
2292
|
}
|
|
2503
2293
|
}
|
|
2504
|
-
function protectedLocalPathsOf(context) {
|
|
2505
|
-
if (context.config.sourcePath == null) {
|
|
2506
|
-
return [];
|
|
2507
|
-
}
|
|
2508
|
-
return [context.config.sourcePath];
|
|
2509
|
-
}
|
|
2510
2294
|
|
|
2511
2295
|
// src/modules/tools/exec-tools.ts
|
|
2512
2296
|
function registerExecTools(server, context) {
|
|
@@ -2515,14 +2299,13 @@ function registerExecTools(server, context) {
|
|
|
2515
2299
|
{
|
|
2516
2300
|
title: "Run a command over SSH",
|
|
2517
2301
|
description: [
|
|
2518
|
-
"Run a shell command on
|
|
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 (fs2__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
|
+
fs2__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();
|
|
@@ -3074,14 +2788,11 @@ function createSshMcpServer(options) {
|
|
|
3074
2788
|
}
|
|
3075
2789
|
|
|
3076
2790
|
exports.$logger = $logger;
|
|
3077
|
-
exports.CONFIG_INPUT_SCHEMA = CONFIG_INPUT_SCHEMA;
|
|
3078
|
-
exports.CONFIG_PATH_ENV = CONFIG_PATH_ENV;
|
|
3079
2791
|
exports.GuardRejectionError = GuardRejectionError;
|
|
3080
|
-
exports.HOST_INPUT_SCHEMA = HOST_INPUT_SCHEMA;
|
|
3081
2792
|
exports.LOG_LEVEL_ENV = LOG_LEVEL_ENV;
|
|
3082
|
-
exports.POLICY_INPUT_SCHEMA = POLICY_INPUT_SCHEMA;
|
|
3083
2793
|
exports.SERVER_NAME = SERVER_NAME;
|
|
3084
2794
|
exports.SERVER_VERSION = SERVER_VERSION;
|
|
2795
|
+
exports.SINGLE_HOST_ENV = SINGLE_HOST_ENV;
|
|
3085
2796
|
exports.SshConnection = SshConnection;
|
|
3086
2797
|
exports.SshSession = SshSession;
|
|
3087
2798
|
exports.SshSessionManager = SshSessionManager;
|
|
@@ -3095,7 +2806,7 @@ exports.inspectLocalPath = inspectLocalPath;
|
|
|
3095
2806
|
exports.inspectRemotePath = inspectRemotePath;
|
|
3096
2807
|
exports.inspectWorkingDirectory = inspectWorkingDirectory;
|
|
3097
2808
|
exports.listRemoteDirectory = listRemoteDirectory;
|
|
3098
|
-
exports.
|
|
2809
|
+
exports.loadHostProfile = loadHostProfile;
|
|
3099
2810
|
exports.normalizeCommandText = normalizeCommandText;
|
|
3100
2811
|
exports.readRemoteFile = readRemoteFile;
|
|
3101
2812
|
exports.registerAllTools = registerAllTools;
|