@akms/mcp-ssh 0.0.1 → 0.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js CHANGED
@@ -1,22 +1,17 @@
1
1
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
2
  import os from 'os';
3
- import path3 from 'path';
4
- import fs from 'fs';
5
- import { z } from 'zod';
3
+ import path2 from 'path';
4
+ import fs2 from 'fs';
6
5
  import { createHash } from 'crypto';
7
6
  import { StringDecoder } from 'string_decoder';
8
7
  import ssh2 from 'ssh2';
9
8
  import { setInterval, clearInterval } from 'timers';
9
+ import { z } from 'zod';
10
10
 
11
11
  // src/server.ts
12
12
 
13
13
  // src/_defs/defaults.ts
14
- var CONFIG_PATH_ENV = "SSH_MCP_CONFIG";
15
14
  var LOG_LEVEL_ENV = "SSH_MCP_LOG_LEVEL";
16
- var DEFAULT_CONFIG_FILENAMES = [
17
- ".akms-ssh.json",
18
- ".config/akms-ssh/config.json"
19
- ];
20
15
  var DEFAULT_SSH_PORT = 22;
21
16
  var DEFAULT_EXEC_TIMEOUT_MS = 6e4;
22
17
  var DEFAULT_CONNECT_TIMEOUT_MS = 2e4;
@@ -27,8 +22,8 @@ var SESSION_IDLE_TIMEOUT_MS = 15 * 60 * 1e3;
27
22
  var SESSION_SWEEP_INTERVAL_MS = 6e4;
28
23
  var CWD_MARKER = "__AKMS_SSH_CWD__";
29
24
  var MAX_DIRECTORY_ENTRIES = 1e3;
30
- var SERVER_NAME = "akms-ssh";
31
- var SERVER_VERSION = "0.0.1";
25
+ var SERVER_NAME = "akms-mcp-ssh";
26
+ var SERVER_VERSION = "0.0.2";
32
27
 
33
28
  // src/_defs/command-rules.ts
34
29
  var CRITICAL_PATH_PATTERN = new RegExp(
@@ -411,10 +406,6 @@ var PROTECTED_LOCAL_PATH_RULES = [
411
406
  pattern: /(^|\/)\.netrc$/i,
412
407
  reason: "netrc credentials"
413
408
  },
414
- {
415
- pattern: /(^|\/)\.?akms-ssh(\.json)?(\/|$)/i,
416
- reason: "this server's own host profile configuration"
417
- },
418
409
  {
419
410
  pattern: /(^|\/)\.claude(\.json)?(\/|$)/i,
420
411
  reason: "MCP client configuration"
@@ -439,7 +430,7 @@ var PROTECTED_LOCAL_PATH_RULES = [
439
430
 
440
431
  // src/_defs/env-vars.ts
441
432
  var SINGLE_HOST_ENV = {
442
- /** Presence of this variable selects single-host mode. */
433
+ /** Presence of this variable is what registers a host at all. */
443
434
  HOST: "SSH_HOST",
444
435
  PORT: "SSH_PORT",
445
436
  USER: "SSH_USER",
@@ -585,15 +576,15 @@ function expandHomePath(inputPath) {
585
576
  if (inputPath === "~") {
586
577
  expanded = os.homedir();
587
578
  } else if (inputPath.startsWith("~/") == true || inputPath.startsWith("~\\") == true) {
588
- expanded = path3.join(os.homedir(), inputPath.slice(2));
579
+ expanded = path2.join(os.homedir(), inputPath.slice(2));
589
580
  }
590
- return path3.resolve(expanded);
581
+ return path2.resolve(expanded);
591
582
  }
592
583
  function isAbsoluteRemotePath(remotePath) {
593
584
  return remotePath.startsWith("/");
594
585
  }
595
586
  function normalizeRemotePath(remotePath) {
596
- return path3.posix.normalize(remotePath);
587
+ return path2.posix.normalize(remotePath);
597
588
  }
598
589
  function isRemotePathWithin(candidate, allowedPrefix) {
599
590
  const normalizedCandidate = normalizeRemotePath(candidate);
@@ -606,43 +597,6 @@ function isRemotePathWithin(candidate, allowedPrefix) {
606
597
  }
607
598
  return normalizedCandidate.startsWith(`${normalizedPrefix}/`);
608
599
  }
609
- var POLICY_INPUT_SCHEMA = z.strictObject({
610
- readonly: z.boolean().optional(),
611
- allowSudo: z.boolean().optional(),
612
- execTimeoutMs: z.number().int().positive().optional(),
613
- connectTimeoutMs: z.number().int().positive().optional(),
614
- maxOutputCharacters: z.number().int().positive().optional(),
615
- maxReadFileBytes: z.number().int().positive().optional(),
616
- allowCommands: z.array(z.string()).optional(),
617
- denyPatterns: z.array(z.string()).optional(),
618
- allowedPaths: z.array(z.string()).optional()
619
- });
620
- var HOST_INPUT_SCHEMA = POLICY_INPUT_SCHEMA.extend({
621
- // Kept strict by `.extend()` on a strict base — verified by the config tests.
622
- host: z.string().min(1),
623
- port: z.number().int().min(1).max(65535).optional(),
624
- username: z.string().min(1),
625
- description: z.string().optional(),
626
- privateKeyPath: z.string().optional(),
627
- privateKey: z.string().optional(),
628
- passphrase: z.string().optional(),
629
- /** Name of an env var holding the passphrase, preferred over inlining it. */
630
- passphraseEnv: z.string().optional(),
631
- password: z.string().optional(),
632
- /** Name of an env var holding the password, preferred over inlining it. */
633
- passwordEnv: z.string().optional(),
634
- /** Pinned server host key, e.g. "SHA256:abc123…". */
635
- hostKeyFingerprint: z.string().optional(),
636
- defaultCwd: z.string().optional()
637
- });
638
- var CONFIG_INPUT_SCHEMA = z.strictObject({
639
- /** Ignored at runtime; present so editors can attach a JSON schema. */
640
- $schema: z.string().optional(),
641
- defaults: POLICY_INPUT_SCHEMA.optional(),
642
- hosts: z.record(z.string(), HOST_INPUT_SCHEMA)
643
- });
644
-
645
- // src/modules/config/env-host.ts
646
600
  function readEnv(name) {
647
601
  const value = process.env[name];
648
602
  if (value == null) {
@@ -690,220 +644,84 @@ function readListEnv(name) {
690
644
  }
691
645
  return items;
692
646
  }
693
- function buildConfigFromEnv() {
694
- const host = readEnv(SINGLE_HOST_ENV.HOST);
695
- if (host == null) {
696
- return null;
697
- }
698
- const username = readEnv(SINGLE_HOST_ENV.USER);
699
- if (username == null) {
700
- throw new Error(`${SINGLE_HOST_ENV.HOST} is set, so ${SINGLE_HOST_ENV.USER} is required`);
701
- }
702
- const hostInput = {
703
- host,
704
- username,
705
- port: readIntegerEnv(SINGLE_HOST_ENV.PORT),
706
- description: readEnv(SINGLE_HOST_ENV.DESCRIPTION),
707
- privateKeyPath: readEnv(SINGLE_HOST_ENV.KEY),
708
- passphrase: readEnv(SINGLE_HOST_ENV.PASSPHRASE),
709
- password: readEnv(SINGLE_HOST_ENV.PASSWORD),
710
- hostKeyFingerprint: readEnv(SINGLE_HOST_ENV.HOST_KEY),
711
- defaultCwd: readEnv(SINGLE_HOST_ENV.CWD),
712
- readonly: readBooleanEnv(SINGLE_HOST_ENV.READONLY),
713
- allowSudo: readBooleanEnv(SINGLE_HOST_ENV.ALLOW_SUDO),
714
- allowCommands: readListEnv(SINGLE_HOST_ENV.ALLOW_COMMANDS),
715
- denyPatterns: readListEnv(SINGLE_HOST_ENV.DENY_PATTERNS),
716
- allowedPaths: readListEnv(SINGLE_HOST_ENV.ALLOWED_PATHS),
717
- execTimeoutMs: readIntegerEnv(SINGLE_HOST_ENV.EXEC_TIMEOUT_MS),
718
- connectTimeoutMs: readIntegerEnv(SINGLE_HOST_ENV.CONNECT_TIMEOUT_MS),
719
- maxOutputCharacters: readIntegerEnv(SINGLE_HOST_ENV.MAX_OUTPUT_CHARACTERS),
720
- maxReadFileBytes: readIntegerEnv(SINGLE_HOST_ENV.MAX_READ_FILE_BYTES)
721
- };
722
- const name = readEnv(SINGLE_HOST_ENV.NAME) ?? host;
723
- const _logger = $logger.child({ context: "buildConfigFromEnv", profile: name });
724
- _logger.debug("single-host config assembled from environment variables");
725
- return { hosts: { [name]: hostInput } };
726
- }
727
-
728
- // src/modules/config/load-config.ts
729
- function resolveConfigSource(explicitPath) {
730
- if (explicitPath != null && explicitPath.trim() !== "") {
731
- return { kind: "file", path: expandHomePath(explicitPath) };
732
- }
733
- const fromSingleHostEnv = buildConfigFromEnv();
734
- if (fromSingleHostEnv != null) {
735
- return { kind: "env", config: fromSingleHostEnv };
736
- }
737
- const fromEnv = process.env[CONFIG_PATH_ENV];
738
- if (fromEnv != null && fromEnv.trim() !== "") {
739
- const trimmedValue = fromEnv.trim();
740
- if (trimmedValue.startsWith("{") == true) {
741
- return { kind: "inline", json: trimmedValue };
742
- }
743
- return { kind: "file", path: expandHomePath(trimmedValue) };
744
- }
745
- for (const filename of DEFAULT_CONFIG_FILENAMES) {
746
- const candidate = path3.join(os.homedir(), filename);
747
- if (fs.existsSync(candidate) == true) {
748
- return { kind: "file", path: candidate };
749
- }
750
- }
751
- return { kind: "none" };
752
- }
753
- function compileDenyPatterns(profileName, sources) {
647
+ function compileDenyPatterns(sources) {
754
648
  return sources.map((source) => {
755
649
  try {
756
650
  return new RegExp(source, "i");
757
651
  } catch (ex) {
758
- const _logger = $logger.child({ context: "compileDenyPatterns", profile: profileName });
652
+ const _logger = $logger.child({ context: "compileDenyPatterns" });
759
653
  _logger.error(ex, "denyPatterns entry is not a valid regular expression");
760
654
  let detail = String(ex);
761
655
  if (ex instanceof Error) {
762
656
  detail = ex.message;
763
657
  }
764
- throw new Error(`profile '${profileName}' has an invalid denyPatterns entry /${source}/: ${detail}`);
658
+ throw new Error(`${SINGLE_HOST_ENV.DENY_PATTERNS} has an invalid entry /${source}/: ${detail}`);
765
659
  }
766
660
  });
767
661
  }
768
- function resolvePolicy(profileName, defaults, override) {
769
- const allowCommands = [...override.allowCommands ?? defaults?.allowCommands ?? []];
770
- if (allowCommands.length > 0 && allowCommands.includes("pwd") == false) {
771
- allowCommands.push("pwd");
662
+ function loadHostProfile() {
663
+ const host = readEnv(SINGLE_HOST_ENV.HOST);
664
+ if (host == null) {
665
+ const _startupLogger = $logger.child({ context: "loadHostProfile" });
666
+ _startupLogger.warn("no host is registered; set the connection variables and restart", {
667
+ env_vars: [SINGLE_HOST_ENV.HOST, SINGLE_HOST_ENV.USER]
668
+ });
669
+ return null;
772
670
  }
773
- const denyPatternSources = override.denyPatterns ?? defaults?.denyPatterns ?? [];
774
- const allowedPaths = override.allowedPaths ?? defaults?.allowedPaths ?? [];
775
- const _logger = $logger.child({ context: "resolvePolicy", profile: profileName });
776
- if (override.allowCommands?.length === 0 && (defaults?.allowCommands?.length ?? 0) > 0) {
777
- _logger.warn("host sets allowCommands to an empty list, which removes the restriction from defaults");
671
+ const username = readEnv(SINGLE_HOST_ENV.USER);
672
+ if (username == null) {
673
+ throw new Error(`${SINGLE_HOST_ENV.HOST} is set, so ${SINGLE_HOST_ENV.USER} is required`);
778
674
  }
779
- if (override.allowedPaths?.length === 0 && (defaults?.allowedPaths?.length ?? 0) > 0) {
780
- _logger.warn("host sets allowedPaths to an empty list, which removes the restriction from defaults");
675
+ const name = readEnv(SINGLE_HOST_ENV.NAME) ?? host;
676
+ const _logger = $logger.child({ context: "loadHostProfile", profile: name });
677
+ const allowCommands = readListEnv(SINGLE_HOST_ENV.ALLOW_COMMANDS) ?? [];
678
+ if (allowCommands.length > 0 && allowCommands.includes("pwd") == false) {
679
+ allowCommands.push("pwd");
781
680
  }
782
- return {
783
- readonly: override.readonly ?? defaults?.readonly ?? false,
784
- allowSudo: override.allowSudo ?? defaults?.allowSudo ?? true,
785
- execTimeoutMs: override.execTimeoutMs ?? defaults?.execTimeoutMs ?? DEFAULT_EXEC_TIMEOUT_MS,
786
- connectTimeoutMs: override.connectTimeoutMs ?? defaults?.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS,
787
- maxOutputCharacters: override.maxOutputCharacters ?? defaults?.maxOutputCharacters ?? DEFAULT_MAX_OUTPUT_CHARACTERS,
788
- maxReadFileBytes: override.maxReadFileBytes ?? defaults?.maxReadFileBytes ?? DEFAULT_MAX_READ_FILE_BYTES,
681
+ const policy = {
682
+ readonly: readBooleanEnv(SINGLE_HOST_ENV.READONLY) ?? false,
683
+ allowSudo: readBooleanEnv(SINGLE_HOST_ENV.ALLOW_SUDO) ?? true,
684
+ execTimeoutMs: readIntegerEnv(SINGLE_HOST_ENV.EXEC_TIMEOUT_MS) ?? DEFAULT_EXEC_TIMEOUT_MS,
685
+ connectTimeoutMs: readIntegerEnv(SINGLE_HOST_ENV.CONNECT_TIMEOUT_MS) ?? DEFAULT_CONNECT_TIMEOUT_MS,
686
+ maxOutputCharacters: readIntegerEnv(SINGLE_HOST_ENV.MAX_OUTPUT_CHARACTERS) ?? DEFAULT_MAX_OUTPUT_CHARACTERS,
687
+ maxReadFileBytes: readIntegerEnv(SINGLE_HOST_ENV.MAX_READ_FILE_BYTES) ?? DEFAULT_MAX_READ_FILE_BYTES,
789
688
  allowCommands,
790
- denyPatterns: compileDenyPatterns(profileName, denyPatternSources),
791
- allowedPaths
689
+ denyPatterns: compileDenyPatterns(readListEnv(SINGLE_HOST_ENV.DENY_PATTERNS) ?? []),
690
+ allowedPaths: readListEnv(SINGLE_HOST_ENV.ALLOWED_PATHS) ?? []
792
691
  };
793
- }
794
- function resolveSecret(profileName, inlineValue, envVarName) {
795
- if (envVarName == null) {
796
- return inlineValue;
797
- }
798
- const fromEnv = process.env[envVarName];
799
- if (fromEnv == null || fromEnv === "") {
800
- const _logger = $logger.child({ context: "resolveSecret", profile: profileName, env_var: envVarName });
801
- _logger.warn("env var referenced by the profile is empty; falling back to the inline value");
802
- return inlineValue;
803
- }
804
- return fromEnv;
805
- }
806
- function toHostProfile(name, raw, defaults) {
807
692
  const profile = {
808
693
  name,
809
- host: raw.host,
810
- port: raw.port ?? DEFAULT_SSH_PORT,
811
- username: raw.username,
812
- description: raw.description,
813
- privateKey: raw.privateKey,
814
- passphrase: resolveSecret(name, raw.passphrase, raw.passphraseEnv),
815
- password: resolveSecret(name, raw.password, raw.passwordEnv),
816
- hostKeyFingerprint: raw.hostKeyFingerprint,
817
- defaultCwd: raw.defaultCwd,
818
- policy: resolvePolicy(name, defaults, raw)
694
+ host,
695
+ port: readIntegerEnv(SINGLE_HOST_ENV.PORT) ?? DEFAULT_SSH_PORT,
696
+ username,
697
+ description: readEnv(SINGLE_HOST_ENV.DESCRIPTION),
698
+ passphrase: readEnv(SINGLE_HOST_ENV.PASSPHRASE),
699
+ password: readEnv(SINGLE_HOST_ENV.PASSWORD),
700
+ hostKeyFingerprint: readEnv(SINGLE_HOST_ENV.HOST_KEY),
701
+ defaultCwd: readEnv(SINGLE_HOST_ENV.CWD),
702
+ policy
819
703
  };
820
- if (raw.privateKeyPath != null) {
821
- const expandedKeyPath = expandHomePath(raw.privateKeyPath);
704
+ const keyPath = readEnv(SINGLE_HOST_ENV.KEY);
705
+ if (keyPath != null) {
706
+ const expandedKeyPath = expandHomePath(keyPath);
822
707
  profile.privateKeyPath = expandedKeyPath;
823
- if (fs.existsSync(expandedKeyPath) == false) {
824
- const _logger = $logger.child({ context: "toHostProfile", profile: name });
825
- _logger.warn("privateKeyPath does not exist; connections for this profile will fail", {
708
+ if (fs2.existsSync(expandedKeyPath) == false) {
709
+ _logger.warn(`${SINGLE_HOST_ENV.KEY} does not exist; connections will fail`, {
826
710
  private_key_path: expandedKeyPath
827
711
  });
828
712
  }
829
713
  }
830
- return profile;
831
- }
832
- function warnOnLoosePermissions(configPath) {
833
- if (process.platform === "win32") {
834
- return;
835
- }
836
- const stats = fs.statSync(configPath);
837
- const permissionBits = stats.mode & 511;
838
- if ((permissionBits & 63) === 0) {
839
- return;
840
- }
841
- const _logger = $logger.child({ context: "warnOnLoosePermissions", config_path: configPath });
842
- _logger.warn(`config file is readable by other users, run: chmod 600 ${configPath}`, {
843
- mode: permissionBits.toString(8)
844
- });
845
- }
846
- function loadConfig(explicitPath) {
847
- let _logger = $logger.child({ context: "loadConfig" });
848
- const source = resolveConfigSource(explicitPath);
849
- if (source.kind === "none") {
850
- _logger.warn("no config found; no host profiles are registered", {
851
- searched: DEFAULT_CONFIG_FILENAMES,
852
- env_vars: [SINGLE_HOST_ENV.HOST, CONFIG_PATH_ENV]
853
- });
854
- return { profiles: /* @__PURE__ */ new Map(), sourcePath: null, sourceLabel: "none" };
855
- }
856
- let parsedJson = null;
857
- let sourcePath = null;
858
- let sourceLabel = `inline ${CONFIG_PATH_ENV}`;
859
- if (source.kind === "env") {
860
- _logger = _logger.child({ config_source: "env" });
861
- sourceLabel = `${SINGLE_HOST_ENV.HOST} environment variables`;
862
- parsedJson = source.config;
863
- } else {
864
- let rawText = "";
865
- if (source.kind === "inline") {
866
- _logger = _logger.child({ config_source: "inline" });
867
- rawText = source.json;
868
- } else {
869
- _logger = _logger.child({ config_path: source.path });
870
- sourcePath = source.path;
871
- sourceLabel = source.path;
872
- if (fs.existsSync(source.path) == false) {
873
- _logger.error(null, "config file does not exist");
874
- throw new Error(`ssh mcp config not found: ${source.path}`);
875
- }
876
- rawText = fs.readFileSync(source.path, "utf8");
877
- }
878
- try {
879
- parsedJson = JSON.parse(rawText);
880
- } catch (ex) {
881
- _logger.error(ex, "config is not valid json");
882
- throw new Error(`ssh mcp config is not valid JSON (${sourceLabel})`);
883
- }
884
- }
885
- const validated = CONFIG_INPUT_SCHEMA.safeParse(parsedJson);
886
- if (validated.success == false) {
887
- const issueSummary = validated.error.issues.map((issue) => `${issue.path.join(".")}: ${issue.message}`).join("; ");
888
- _logger.error(validated.error, "config failed schema validation");
889
- throw new Error(`ssh mcp config is invalid (${sourceLabel}): ${issueSummary}`);
890
- }
891
- if (sourcePath != null) {
892
- warnOnLoosePermissions(sourcePath);
893
- }
894
- const profiles = /* @__PURE__ */ new Map();
895
- for (const [name, hostInput] of Object.entries(validated.data.hosts)) {
896
- profiles.set(name, toHostProfile(name, hostInput, validated.data.defaults));
897
- }
898
- _logger.info("config loaded", {
899
- profile_count: profiles.size,
900
- profiles: Array.from(profiles.keys())
714
+ _logger.info("host profile loaded", {
715
+ host,
716
+ port: profile.port,
717
+ username,
718
+ readonly: policy.readonly
901
719
  });
902
- return { profiles, sourcePath, sourceLabel };
720
+ return profile;
903
721
  }
904
722
  function toHostSummary(profile) {
905
723
  let authMethod = "agent";
906
- if (profile.privateKeyPath != null || profile.privateKey != null) {
724
+ if (profile.privateKeyPath != null) {
907
725
  authMethod = "privateKey";
908
726
  } else if (profile.password != null) {
909
727
  authMethod = "password";
@@ -1358,20 +1176,20 @@ function inspectRemotePath(remotePath, policy, access) {
1358
1176
  function toRealPath(inputPath) {
1359
1177
  const _logger = $logger.child({ context: "toRealPath" });
1360
1178
  try {
1361
- return fs.realpathSync.native(inputPath);
1179
+ return fs2.realpathSync.native(inputPath);
1362
1180
  } catch (ex) {
1363
1181
  _logger.debug("path does not resolve, falling back to its parent directory", {
1364
1182
  reason: ex instanceof Error ? ex.name : "unknown"
1365
1183
  });
1366
1184
  }
1367
1185
  try {
1368
- const parentRealPath = fs.realpathSync.native(path3.dirname(inputPath));
1369
- return path3.join(parentRealPath, path3.basename(inputPath));
1186
+ const parentRealPath = fs2.realpathSync.native(path2.dirname(inputPath));
1187
+ return path2.join(parentRealPath, path2.basename(inputPath));
1370
1188
  } catch (ex) {
1371
1189
  _logger.debug("parent directory does not resolve either, comparing lexically", {
1372
1190
  reason: ex instanceof Error ? ex.name : "unknown"
1373
1191
  });
1374
- return path3.resolve(inputPath);
1192
+ return path2.resolve(inputPath);
1375
1193
  }
1376
1194
  }
1377
1195
  function toComparablePath(inputPath) {
@@ -1457,7 +1275,7 @@ function readPrivateKey(profile, keyPath) {
1457
1275
  return cached;
1458
1276
  }
1459
1277
  try {
1460
- const contents = fs.readFileSync(keyPath);
1278
+ const contents = fs2.readFileSync(keyPath);
1461
1279
  PRIVATE_KEY_CACHE.set(keyPath, contents);
1462
1280
  return contents;
1463
1281
  } catch (ex) {
@@ -1503,8 +1321,6 @@ function buildConnectConfig(profile, onHostKeySeen) {
1503
1321
  };
1504
1322
  if (profile.privateKeyPath != null) {
1505
1323
  connectConfig.privateKey = readPrivateKey(profile, profile.privateKeyPath);
1506
- } else if (profile.privateKey != null) {
1507
- connectConfig.privateKey = profile.privateKey;
1508
1324
  }
1509
1325
  if (profile.passphrase != null) {
1510
1326
  connectConfig.passphrase = profile.passphrase;
@@ -1521,7 +1337,7 @@ function buildRemoteCommand(command, cwd, trackCwd) {
1521
1337
  const lines = [];
1522
1338
  if (cwd != null && cwd !== "") {
1523
1339
  const quotedCwd = quoteShellArgument(cwd);
1524
- lines.push(`cd ${quotedCwd} 2>/dev/null || { echo 'akms-ssh: cannot enter the requested working directory' >&2; exit 1; }`);
1340
+ lines.push(`cd ${quotedCwd} 2>/dev/null || { echo 'akms-mcp-ssh: cannot enter the requested working directory' >&2; exit 1; }`);
1525
1341
  }
1526
1342
  lines.push(command);
1527
1343
  if (trackCwd == true) {
@@ -2234,7 +2050,7 @@ async function writeRemoteFile(connection, args) {
2234
2050
  }
2235
2051
  async function uploadFile(connection, localPath, remotePath) {
2236
2052
  requirePermittedPath(connection, remotePath, "write");
2237
- const localStats = fs.statSync(localPath);
2053
+ const localStats = fs2.statSync(localPath);
2238
2054
  if (localStats.isFile() == false) {
2239
2055
  throw new Error(`local path is not a file: ${localPath}`);
2240
2056
  }
@@ -2271,7 +2087,7 @@ async function downloadFile(connection, remotePath, localPath) {
2271
2087
  });
2272
2088
  });
2273
2089
  });
2274
- const localStats = fs.statSync(localPath);
2090
+ const localStats = fs2.statSync(localPath);
2275
2091
  const _logger = $logger.child({
2276
2092
  context: "downloadFile",
2277
2093
  profile: connection.profile.name,
@@ -2289,44 +2105,42 @@ var ENTRY_TYPE_MARKERS = {
2289
2105
  symlink: "l",
2290
2106
  other: "?"
2291
2107
  };
2292
- function formatHostList(config) {
2293
- if (config.profiles.size === 0) {
2108
+ function formatHostProfile(profile) {
2109
+ if (profile == null) {
2294
2110
  return [
2295
- "No SSH host profiles are registered.",
2111
+ "No SSH host is registered.",
2296
2112
  "",
2297
- `Put them in this server's MCP entry \u2014 ${CONFIG_PATH_ENV} takes the JSON itself:`,
2113
+ "Put the connection in this server's MCP entry \u2014 one server entry per host:",
2298
2114
  "",
2299
2115
  ` "env": {`,
2300
- ` "${CONFIG_PATH_ENV}": "{\\"hosts\\":{\\"my-server\\":{\\"host\\":\\"10.0.0.5\\",\\"username\\":\\"deploy\\",\\"privateKeyPath\\":\\"~/.ssh/id_ed25519\\"}}}"`,
2116
+ ` "${SINGLE_HOST_ENV.HOST}": "10.0.0.5",`,
2117
+ ` "${SINGLE_HOST_ENV.USER}": "deploy",`,
2118
+ ` "${SINGLE_HOST_ENV.KEY}": "~/.ssh/id_ed25519"`,
2301
2119
  " }",
2302
2120
  "",
2303
- `It also accepts a path to a JSON file, and ~/${DEFAULT_CONFIG_FILENAMES[0]} is picked up automatically.`,
2304
- "Restart this server after changing it."
2121
+ `${SINGLE_HOST_ENV.HOST} and ${SINGLE_HOST_ENV.USER} are the whole minimum.`,
2122
+ "Restart this server after changing it \u2014 environment variables are fixed for the life of the process."
2305
2123
  ].join("\n");
2306
2124
  }
2125
+ const summary = toHostSummary(profile);
2126
+ let accessLabel = "writable";
2127
+ if (summary.readonly == true) {
2128
+ accessLabel = "READ-ONLY";
2129
+ }
2130
+ let sudoLabel = "sudo=disabled";
2131
+ if (summary.allowSudo == true) {
2132
+ sudoLabel = "sudo=enabled";
2133
+ }
2307
2134
  const lines = [];
2308
- lines.push(`${config.profiles.size} SSH host profile(s) from ${config.sourceLabel}:`);
2309
- for (const profile of config.profiles.values()) {
2310
- const summary = toHostSummary(profile);
2311
- let accessLabel = "writable";
2312
- if (summary.readonly == true) {
2313
- accessLabel = "READ-ONLY";
2314
- }
2315
- let sudoLabel = "sudo=disabled";
2316
- if (summary.allowSudo == true) {
2317
- sudoLabel = "sudo=enabled";
2318
- }
2319
- lines.push("");
2320
- lines.push(`${summary.name} \u2192 ${summary.username}@${summary.host}:${summary.port} auth=${summary.authMethod} ${accessLabel} ${sudoLabel}`);
2321
- if (summary.description != null) {
2322
- lines.push(` ${summary.description}`);
2323
- }
2324
- if (summary.allowedPaths.length > 0) {
2325
- lines.push(` file tools limited to: ${summary.allowedPaths.join(", ")} (does not restrict ssh_exec)`);
2326
- }
2327
- if (summary.allowCommands.length > 0) {
2328
- lines.push(` commands limited to: ${summary.allowCommands.join(", ")}`);
2329
- }
2135
+ lines.push(`${summary.name} \u2192 ${summary.username}@${summary.host}:${summary.port} auth=${summary.authMethod} ${accessLabel} ${sudoLabel}`);
2136
+ if (summary.description != null) {
2137
+ lines.push(` ${summary.description}`);
2138
+ }
2139
+ if (summary.allowedPaths.length > 0) {
2140
+ lines.push(` file tools limited to: ${summary.allowedPaths.join(", ")} (does not restrict ssh_exec)`);
2141
+ }
2142
+ if (summary.allowCommands.length > 0) {
2143
+ lines.push(` commands limited to: ${summary.allowCommands.join(", ")}`);
2330
2144
  }
2331
2145
  return lines.join("\n");
2332
2146
  }
@@ -2438,40 +2252,16 @@ function toToolError(error, toolName, action) {
2438
2252
  _logger.error(error, `${toolName} failed`);
2439
2253
  return errorResult(`${toolName} failed: ${describeError(error)}`);
2440
2254
  }
2441
- function requireProfile(context, target) {
2442
- const profile = context.config.profiles.get(target);
2443
- if (profile != null) {
2444
- return profile;
2445
- }
2446
- const available = Array.from(context.config.profiles.keys());
2447
- if (available.length === 0) {
2448
- throw new Error(`no host profiles are registered \u2014 set ${SINGLE_HOST_ENV.HOST} / ${SINGLE_HOST_ENV.USER} in this server's env block (or ${CONFIG_PATH_ENV} for several hosts), then restart it`);
2449
- }
2450
- throw new Error(`unknown host profile '${target}' (registered: ${available.join(", ")})`);
2451
- }
2452
2255
  function resolveProfile(context, args) {
2453
- const hasTarget = args.target != null && args.target.trim() !== "";
2454
2256
  const hasSession = args.session != null && args.session.trim() !== "";
2455
- if (hasTarget == true && hasSession == true) {
2456
- throw new Error("pass either 'target' or 'session', not both \u2014 they can name different hosts with different policies");
2457
- }
2458
2257
  if (hasSession == true) {
2459
2258
  const session = context.sessions.require(args.session.trim());
2460
2259
  return { profile: session.profile, session };
2461
2260
  }
2462
- if (hasTarget == true) {
2463
- const profile = requireProfile(context, args.target.trim());
2464
- return { profile, session: null };
2465
- }
2466
- const onlyProfile = context.config.profiles.values().next().value;
2467
- if (context.config.profiles.size === 1 && onlyProfile != null) {
2468
- return { profile: onlyProfile, session: null };
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`);
2469
2263
  }
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`);
2472
- }
2473
- const available = Array.from(context.config.profiles.keys());
2474
- throw new Error(`this server fronts several hosts, so 'target' is required (registered: ${available.join(", ")})`);
2264
+ return { profile: context.profile, session: null };
2475
2265
  }
2476
2266
  async function withHostConnection(profile, session, operation) {
2477
2267
  if (session != null) {
@@ -2492,12 +2282,6 @@ async function withHostConnection(profile, session, operation) {
2492
2282
  }
2493
2283
  }
2494
2284
  }
2495
- function protectedLocalPathsOf(context) {
2496
- if (context.config.sourcePath == null) {
2497
- return [];
2498
- }
2499
- return [context.config.sourcePath];
2500
- }
2501
2285
 
2502
2286
  // src/modules/tools/exec-tools.ts
2503
2287
  function registerExecTools(server, context) {
@@ -2506,14 +2290,13 @@ function registerExecTools(server, context) {
2506
2290
  {
2507
2291
  title: "Run a command over SSH",
2508
2292
  description: [
2509
- "Run a shell command on a registered SSH host and return stdout, stderr and the exit code.",
2510
- "With a single configured host, no target is needed. Pass 'session' to reuse an open session (which remembers its working directory), or 'target' to pick a host when several are registered \u2014 one or the other, never both.",
2293
+ "Run a shell command on the configured SSH host and return stdout, stderr and the exit code.",
2294
+ "Pass 'session' to reuse an open session, which remembers its working directory; omit it and a one-off connection is opened and closed around the call.",
2511
2295
  "A non-zero exit code is reported as normal output, not as a tool error \u2014 read the exit code and stderr to judge the outcome.",
2512
- "Almost everything is permitted: package installs, service restarts, sudo, interpreters. Only catastrophic operations are refused outright (wiping the filesystem root, formatting a disk, rebooting, flushing the firewall), and individual profiles may add their own limits."
2296
+ "Almost everything is permitted: package installs, service restarts, sudo, interpreters. Only catastrophic operations are refused outright (wiping the filesystem root, formatting a disk, rebooting, flushing the firewall), and the host may add its own limits."
2513
2297
  ].join(" "),
2514
2298
  inputSchema: {
2515
2299
  command: z.string().min(1).describe("Shell command, exactly as it would be typed in a terminal. Chaining with ';', '&&', '||' and pipes is allowed; each part is screened separately."),
2516
- target: z.string().optional().describe("Host profile alias. Omit it when this server fronts a single host, which is the usual setup. Opens a one-off connection that is closed afterwards."),
2517
2300
  session: z.string().optional().describe("Session id from ssh_connect. Reuses that connection and keeps the working directory across calls."),
2518
2301
  cwd: z.string().optional().describe("Absolute directory to run in, with no shell metacharacters. With a session, the session also moves there."),
2519
2302
  timeoutMs: z.number().int().positive().optional().describe("Lowers the command timeout for this call; it cannot exceed the profile's own limit. The channel is closed when it elapses; the remote process may survive.")
@@ -2525,10 +2308,10 @@ function registerExecTools(server, context) {
2525
2308
  openWorldHint: true
2526
2309
  }
2527
2310
  },
2528
- async ({ command, target, session, cwd, timeoutMs }) => {
2311
+ async ({ command, session, cwd, timeoutMs }) => {
2529
2312
  let _logger = $logger.child({ context: "ssh_exec" });
2530
2313
  try {
2531
- const { profile, session: openSession } = resolveProfile(context, { target, session });
2314
+ const { profile, session: openSession } = resolveProfile(context, { session });
2532
2315
  _logger = _logger.child({ profile: profile.name, session_id: openSession?.sessionId });
2533
2316
  const requestedTimeoutMs = timeoutMs ?? profile.policy.execTimeoutMs;
2534
2317
  const effectiveTimeoutMs = Math.min(requestedTimeoutMs, profile.policy.execTimeoutMs);
@@ -2564,10 +2347,9 @@ function registerExecTools(server, context) {
2564
2347
  }
2565
2348
  );
2566
2349
  }
2567
- var TARGET_DESCRIPTION = "Host profile alias. Omit it when this server fronts a single host, which is the usual setup.";
2568
- var SESSION_DESCRIPTION = "Session id from ssh_connect, whose connection is reused. Pass this or 'target', not both.";
2569
- function requirePermittedLocalPath(context, localPath, access) {
2570
- const verdict = inspectLocalPath(localPath, access, protectedLocalPathsOf(context));
2350
+ var SESSION_DESCRIPTION = "Session id from ssh_connect, whose connection is reused. Omit it to open a one-off connection.";
2351
+ function requirePermittedLocalPath(localPath, access) {
2352
+ const verdict = inspectLocalPath(localPath, access);
2571
2353
  if (verdict.allowed == false) {
2572
2354
  throw new GuardRejectionError(verdict);
2573
2355
  }
@@ -2578,12 +2360,11 @@ function registerFileTools(server, context) {
2578
2360
  {
2579
2361
  title: "List a remote directory",
2580
2362
  description: [
2581
- "List a directory on a registered SSH host over SFTP, with type, permissions, size and mtime.",
2582
- "Cheaper and more structured than running 'ls -la' through ssh_exec, and it works on read-only profiles."
2363
+ "List a directory on the configured SSH host over SFTP, with type, permissions, size and mtime.",
2364
+ "Cheaper and more structured than running 'ls -la' through ssh_exec, and it works on a read-only host."
2583
2365
  ].join(" "),
2584
2366
  inputSchema: {
2585
- path: z.string().min(1).describe("Absolute remote directory path. Profiles with allowedPaths set accept only paths inside them."),
2586
- target: z.string().optional().describe(TARGET_DESCRIPTION),
2367
+ path: z.string().min(1).describe("Absolute remote directory path. When SSH_ALLOWED_PATHS is set, only paths inside it are accepted."),
2587
2368
  session: z.string().optional().describe(SESSION_DESCRIPTION)
2588
2369
  },
2589
2370
  annotations: {
@@ -2593,10 +2374,10 @@ function registerFileTools(server, context) {
2593
2374
  openWorldHint: true
2594
2375
  }
2595
2376
  },
2596
- async ({ path: remotePath, target, session }) => {
2377
+ async ({ path: remotePath, session }) => {
2597
2378
  const _logger = $logger.child({ context: "ssh_list_dir", remote_path: remotePath });
2598
2379
  try {
2599
- const { profile, session: openSession } = resolveProfile(context, { target, session });
2380
+ const { profile, session: openSession } = resolveProfile(context, { session });
2600
2381
  const listing = await withHostConnection(profile, openSession, async (connection) => {
2601
2382
  return await listRemoteDirectory(connection, remotePath);
2602
2383
  });
@@ -2623,9 +2404,8 @@ function registerFileTools(server, context) {
2623
2404
  ].join(" "),
2624
2405
  inputSchema: {
2625
2406
  path: z.string().min(1).describe("Absolute remote file path."),
2626
- target: z.string().optional().describe(TARGET_DESCRIPTION),
2627
2407
  session: z.string().optional().describe(SESSION_DESCRIPTION),
2628
- maxBytes: z.number().int().positive().optional().describe("Lowers the read ceiling for this call; it cannot exceed the profile's maxReadFileBytes.")
2408
+ maxBytes: z.number().int().positive().optional().describe("Lowers the read ceiling for this call; it cannot exceed SSH_MAX_READ_BYTES.")
2629
2409
  },
2630
2410
  annotations: {
2631
2411
  readOnlyHint: true,
@@ -2634,10 +2414,10 @@ function registerFileTools(server, context) {
2634
2414
  openWorldHint: true
2635
2415
  }
2636
2416
  },
2637
- async ({ path: remotePath, target, session, maxBytes }) => {
2417
+ async ({ path: remotePath, session, maxBytes }) => {
2638
2418
  const _logger = $logger.child({ context: "ssh_read_file", remote_path: remotePath });
2639
2419
  try {
2640
- const { profile, session: openSession } = resolveProfile(context, { target, session });
2420
+ const { profile, session: openSession } = resolveProfile(context, { session });
2641
2421
  const requestedLimit = maxBytes ?? profile.policy.maxReadFileBytes;
2642
2422
  const readLimit = Math.min(requestedLimit, profile.policy.maxReadFileBytes);
2643
2423
  const file = await withHostConnection(profile, openSession, async (connection) => {
@@ -2669,14 +2449,13 @@ ${file.content}`);
2669
2449
  {
2670
2450
  title: "Write a remote file",
2671
2451
  description: [
2672
- "Write or append UTF-8 text to a file on a registered SSH host over SFTP.",
2452
+ "Write or append UTF-8 text to a file on the configured SSH host over SFTP.",
2673
2453
  "Use this instead of 'echo ... > file' through ssh_exec: no shell quoting to get wrong, and it works with multi-line content.",
2674
- "Refused on read-only profiles. The parent directory must already exist."
2454
+ "Refused on a read-only host. The parent directory must already exist."
2675
2455
  ].join(" "),
2676
2456
  inputSchema: {
2677
2457
  path: z.string().min(1).describe("Absolute remote file path. Existing content is replaced unless 'append' is true."),
2678
2458
  content: z.string().describe("UTF-8 text to write."),
2679
- target: z.string().optional().describe(TARGET_DESCRIPTION),
2680
2459
  session: z.string().optional().describe(SESSION_DESCRIPTION),
2681
2460
  append: z.boolean().optional().describe("Append instead of replacing. Defaults to false.")
2682
2461
  },
@@ -2687,10 +2466,10 @@ ${file.content}`);
2687
2466
  openWorldHint: true
2688
2467
  }
2689
2468
  },
2690
- async ({ path: remotePath, content, target, session, append }) => {
2469
+ async ({ path: remotePath, content, session, append }) => {
2691
2470
  const _logger = $logger.child({ context: "ssh_write_file", remote_path: remotePath });
2692
2471
  try {
2693
- const { profile, session: openSession } = resolveProfile(context, { target, session });
2472
+ const { profile, session: openSession } = resolveProfile(context, { session });
2694
2473
  const shouldAppend = append ?? false;
2695
2474
  const writtenBytes = await withHostConnection(profile, openSession, async (connection) => {
2696
2475
  return await writeRemoteFile(connection, {
@@ -2715,14 +2494,13 @@ ${file.content}`);
2715
2494
  {
2716
2495
  title: "Upload a file over SFTP",
2717
2496
  description: [
2718
- "Upload a local file to a registered SSH host over SFTP.",
2719
- "Refused on read-only profiles, and for local paths holding credentials (SSH keys, cloud credentials, this server's own config).",
2497
+ "Upload a local file to the configured SSH host over SFTP.",
2498
+ "Refused on a read-only host, and for local paths holding credentials (SSH keys, cloud credentials).",
2720
2499
  "The remote parent directory must already exist."
2721
2500
  ].join(" "),
2722
2501
  inputSchema: {
2723
2502
  localPath: z.string().min(1).describe("Local file path on the machine running this MCP server. '~' is expanded."),
2724
2503
  remotePath: z.string().min(1).describe("Absolute destination path on the remote host, including the filename."),
2725
- target: z.string().optional().describe(TARGET_DESCRIPTION),
2726
2504
  session: z.string().optional().describe(SESSION_DESCRIPTION)
2727
2505
  },
2728
2506
  annotations: {
@@ -2732,13 +2510,13 @@ ${file.content}`);
2732
2510
  openWorldHint: true
2733
2511
  }
2734
2512
  },
2735
- async ({ localPath, remotePath, target, session }) => {
2513
+ async ({ localPath, remotePath, session }) => {
2736
2514
  const _logger = $logger.child({ context: "ssh_upload", remote_path: remotePath });
2737
2515
  try {
2738
- const { profile, session: openSession } = resolveProfile(context, { target, session });
2516
+ const { profile, session: openSession } = resolveProfile(context, { session });
2739
2517
  const resolvedLocalPath = expandHomePath(localPath);
2740
- requirePermittedLocalPath(context, resolvedLocalPath, "read");
2741
- if (fs.existsSync(resolvedLocalPath) == false) {
2518
+ requirePermittedLocalPath(resolvedLocalPath, "read");
2519
+ if (fs2.existsSync(resolvedLocalPath) == false) {
2742
2520
  return textResult(`local file not found: ${resolvedLocalPath}`);
2743
2521
  }
2744
2522
  const transferredBytes = await withHostConnection(profile, openSession, async (connection) => {
@@ -2756,14 +2534,13 @@ ${file.content}`);
2756
2534
  {
2757
2535
  title: "Download a file over SFTP",
2758
2536
  description: [
2759
- "Download a file from a registered SSH host to the machine running this MCP server.",
2537
+ "Download a file from the configured SSH host to the machine running this MCP server.",
2760
2538
  "Use this for binaries or files too large to read into the conversation; missing local directories are created.",
2761
- "The local destination is overwritten, and paths holding credentials or this server's own configuration are refused."
2539
+ "The local destination is overwritten, and paths holding credentials are refused."
2762
2540
  ].join(" "),
2763
2541
  inputSchema: {
2764
2542
  remotePath: z.string().min(1).describe("Absolute source path on the remote host."),
2765
2543
  localPath: z.string().min(1).describe("Local destination path, including the filename. '~' is expanded. Overwritten if it exists."),
2766
- target: z.string().optional().describe(TARGET_DESCRIPTION),
2767
2544
  session: z.string().optional().describe(SESSION_DESCRIPTION)
2768
2545
  },
2769
2546
  annotations: {
@@ -2773,13 +2550,13 @@ ${file.content}`);
2773
2550
  openWorldHint: true
2774
2551
  }
2775
2552
  },
2776
- async ({ remotePath, localPath, target, session }) => {
2553
+ async ({ remotePath, localPath, session }) => {
2777
2554
  const _logger = $logger.child({ context: "ssh_download", remote_path: remotePath });
2778
2555
  try {
2779
- const { profile, session: openSession } = resolveProfile(context, { target, session });
2556
+ const { profile, session: openSession } = resolveProfile(context, { session });
2780
2557
  const resolvedLocalPath = expandHomePath(localPath);
2781
- requirePermittedLocalPath(context, resolvedLocalPath, "write");
2782
- fs.mkdirSync(path3.dirname(resolvedLocalPath), { recursive: true });
2558
+ requirePermittedLocalPath(resolvedLocalPath, "write");
2559
+ fs2.mkdirSync(path2.dirname(resolvedLocalPath), { recursive: true });
2783
2560
  const transferredBytes = await withHostConnection(profile, openSession, async (connection) => {
2784
2561
  return await downloadFile(connection, remotePath, resolvedLocalPath);
2785
2562
  });
@@ -2797,11 +2574,11 @@ function registerHostTools(server, context) {
2797
2574
  server.registerTool(
2798
2575
  "ssh_list_hosts",
2799
2576
  {
2800
- title: "List SSH host profiles",
2577
+ title: "Show the SSH host",
2801
2578
  description: [
2802
- "List the SSH hosts this server may connect to, with each host's guard policy.",
2803
- "Call this first: every other tool takes a profile alias as its 'target', and no host outside this list can be reached.",
2804
- "Credentials are never returned \u2014 only which auth method the profile uses."
2579
+ "Show the SSH host this server connects to, with its guard policy.",
2580
+ "Call this first: it is the only reachable host, and its policy decides what the other tools will accept.",
2581
+ "Credentials are never returned \u2014 only which auth method the host uses."
2805
2582
  ].join(" "),
2806
2583
  inputSchema: {},
2807
2584
  annotations: {
@@ -2814,59 +2591,11 @@ function registerHostTools(server, context) {
2814
2591
  async () => {
2815
2592
  const _logger = $logger.child({ context: "ssh_list_hosts" });
2816
2593
  try {
2817
- const listing = formatHostList(context.config);
2818
- _logger.debug("host list returned", { profile_count: context.config.profiles.size });
2594
+ const listing = formatHostProfile(context.profile);
2595
+ _logger.debug("host profile returned", { profile: context.profile?.name });
2819
2596
  return textResult(listing);
2820
2597
  } catch (ex) {
2821
- return toToolError(ex, "ssh_list_hosts", "list hosts");
2822
- }
2823
- }
2824
- );
2825
- server.registerTool(
2826
- "ssh_reload_config",
2827
- {
2828
- title: "Reload the host configuration",
2829
- description: [
2830
- "Re-read the host configuration and apply it, without restarting the MCP server.",
2831
- "Use after the operator edits the config file \u2014 adding a host, tightening a policy.",
2832
- "Every open session is closed first, so the new policy is in force everywhere rather than leaving old sessions on the old rules.",
2833
- "Configuration supplied through environment variables is fixed for the life of the process; changing that still needs a restart."
2834
- ].join(" "),
2835
- inputSchema: {},
2836
- annotations: {
2837
- readOnlyHint: false,
2838
- destructiveHint: false,
2839
- idempotentHint: true,
2840
- openWorldHint: false
2841
- }
2842
- },
2843
- async () => {
2844
- const _logger = $logger.child({ context: "ssh_reload_config" });
2845
- try {
2846
- const previousLabel = context.config.sourceLabel;
2847
- const closedSessions = context.sessions.list().length;
2848
- context.sessions.closeAll();
2849
- const reloaded = context.reloadConfig();
2850
- const lines = [
2851
- `Reloaded ${reloaded.profiles.size} host profile(s) from ${reloaded.sourceLabel}.`
2852
- ];
2853
- if (reloaded.profiles.size > 0) {
2854
- lines.push(`Hosts: ${Array.from(reloaded.profiles.keys()).join(", ")}`);
2855
- }
2856
- if (closedSessions > 0) {
2857
- lines.push(`Closed ${closedSessions} open session(s); reopen with ssh_connect if needed.`);
2858
- }
2859
- if (reloaded.sourcePath == null) {
2860
- lines.push("This config comes from environment variables, which cannot change while the server runs \u2014 restart the MCP server to apply edits to them.");
2861
- }
2862
- _logger.info("config reloaded", {
2863
- profile_count: reloaded.profiles.size,
2864
- previous_source: previousLabel,
2865
- config_source: reloaded.sourceLabel
2866
- });
2867
- return textResult(lines.join("\n"));
2868
- } catch (ex) {
2869
- return toToolError(ex, "ssh_reload_config", "reload the configuration");
2598
+ return toToolError(ex, "ssh_list_hosts", "show the host");
2870
2599
  }
2871
2600
  }
2872
2601
  );
@@ -2877,13 +2606,12 @@ function registerSessionTools(server, context) {
2877
2606
  {
2878
2607
  title: "Open an SSH session",
2879
2608
  description: [
2880
- "Open a reusable SSH session to a registered host and return its session id.",
2881
- "Prefer this over one-off 'target' calls when running several commands: it pays the handshake once and remembers the working directory, so a 'cd' in one ssh_exec still applies to the next.",
2609
+ "Open a reusable SSH session to the configured host and return its session id.",
2610
+ "Prefer this over one-off calls when running several commands: it pays the handshake once and remembers the working directory, so a 'cd' in one ssh_exec still applies to the next.",
2882
2611
  "Close it with ssh_disconnect when done; idle sessions are reaped automatically, and a session whose connection drops reports that rather than silently reconnecting."
2883
2612
  ].join(" "),
2884
2613
  inputSchema: {
2885
- target: z.string().optional().describe("Host profile alias. Omit it when this server fronts a single host, which is the usual setup."),
2886
- cwd: z.string().optional().describe("Absolute directory to start in, with no shell metacharacters. Defaults to the profile's defaultCwd, else the login shell's directory.")
2614
+ cwd: z.string().optional().describe("Absolute directory to start in, with no shell metacharacters. Defaults to SSH_CWD, else the login shell's directory.")
2887
2615
  },
2888
2616
  annotations: {
2889
2617
  readOnlyHint: false,
@@ -2892,11 +2620,11 @@ function registerSessionTools(server, context) {
2892
2620
  openWorldHint: true
2893
2621
  }
2894
2622
  },
2895
- async ({ target, cwd }) => {
2896
- let _logger = $logger.child({ context: "ssh_connect", target });
2623
+ async ({ cwd }) => {
2624
+ let _logger = $logger.child({ context: "ssh_connect" });
2897
2625
  let openedSessionId = null;
2898
2626
  try {
2899
- const { profile } = resolveProfile(context, { target });
2627
+ const { profile } = resolveProfile(context, {});
2900
2628
  const session = await context.sessions.open(profile);
2901
2629
  openedSessionId = session.sessionId;
2902
2630
  _logger = _logger.child({ session_id: session.sessionId });
@@ -2921,7 +2649,7 @@ ${probe.stderr.trim()}`);
2921
2649
  `cwd: ${session.cwd}`
2922
2650
  ];
2923
2651
  if (profile.policy.readonly == true) {
2924
- lines.push("This profile is READ-ONLY: writes, redirections and uploads will be rejected.");
2652
+ lines.push("This host is READ-ONLY: writes, redirections and uploads will be rejected.");
2925
2653
  }
2926
2654
  lines.push(`Run commands with ssh_exec({ session: "${session.sessionId}", command: "..." }).`);
2927
2655
  _logger.info("session opened via tool", { cwd: session.cwd });
@@ -2930,7 +2658,7 @@ ${probe.stderr.trim()}`);
2930
2658
  if (openedSessionId != null) {
2931
2659
  context.sessions.close(openedSessionId);
2932
2660
  }
2933
- return toToolError(ex, "ssh_connect", `connect to ${target ?? "the configured host"}`);
2661
+ return toToolError(ex, "ssh_connect", "connect to the configured host");
2934
2662
  }
2935
2663
  }
2936
2664
  );
@@ -2998,31 +2726,30 @@ function registerAllTools(server, context) {
2998
2726
 
2999
2727
  // src/server.ts
3000
2728
  var SERVER_INSTRUCTIONS = [
3001
- "This server runs shell commands and transfers files on remote hosts over SSH.",
3002
- "Only hosts pre-registered in a local config file are reachable \u2014 arbitrary host/credential arguments are not accepted.",
2729
+ "This server runs shell commands and transfers files on one remote host over SSH.",
2730
+ "The host is fixed in this server's own environment \u2014 there is no host argument, and no other machine is reachable through it.",
3003
2731
  "",
3004
- "Usually one server entry fronts one host, so no 'target' argument is needed \u2014 omit it and the configured host is used. ssh_list_hosts shows what is registered and what it permits.",
2732
+ "ssh_list_hosts shows which host this is and what it permits. Call it before assuming anything about access.",
3005
2733
  "",
3006
2734
  "Workflow:",
3007
2735
  "1. One command: ssh_exec with just 'command'.",
3008
2736
  "2. Several commands in a row: ssh_connect once, then ssh_exec with 'session' (the working directory persists across calls), then ssh_disconnect.",
3009
2737
  "3. Files: ssh_list_dir, ssh_read_file, ssh_write_file, ssh_upload, ssh_download \u2014 prefer these over shell equivalents; no quoting to get wrong.",
3010
- "Pass 'target' only when ssh_list_hosts shows several hosts, and never together with 'session'.",
3011
2738
  "",
3012
2739
  "What is permitted: ordinary administration, without asking \u2014 package installs, service restarts, sudo, interpreters, config edits, deployments.",
3013
- "Refused on every profile, and only these: operations that destroy the machine or cut this connection \u2014 wiping the filesystem root or a system directory, deleting a file the host cannot boot without, formatting a disk, writing raw bytes to a block device, rebooting or powering off, flushing the firewall, removing every cron job.",
3014
- "Individual profiles may add their own limits (read-only, an allow-list, deny patterns, path restrictions). ssh_list_hosts shows which apply where.",
2740
+ "Refused always, and only these: operations that destroy the machine or cut this connection \u2014 wiping the filesystem root or a system directory, deleting a file the host cannot boot without, formatting a disk, writing raw bytes to a block device, rebooting or powering off, flushing the firewall, removing every cron job.",
2741
+ "The host may add its own limits (read-only, an allow-list, deny patterns, path restrictions). ssh_list_hosts shows which apply.",
3015
2742
  "",
3016
2743
  "Commands run with no terminal and with stdin closed, so anything that waits for input fails immediately instead of hanging: use non-interactive flags (apt-get -y, sudo -n), 'top -bn1' rather than 'top', and ssh_write_file rather than an editor.",
3017
2744
  "A job that may outlive its timeout should be started detached (nohup \u2026 &, tmux new -d) and polled through its log.",
3018
2745
  "",
3019
2746
  "A guard rejection is a policy decision, not a transient failure \u2014 do not retry the same command; report it and ask before working around it.",
3020
2747
  "A non-zero exit code is returned as normal output; read it and stderr to judge what happened.",
3021
- "You still carry the judgement the guards do not: these are real hosts, and an irreversible action deserves a check with the operator first."
2748
+ "You still carry the judgement the guards do not: this is a real host, and an irreversible action deserves a check with the operator first."
3022
2749
  ].join("\n");
3023
- function createSshMcpServer(options) {
2750
+ function createSshMcpServer() {
3024
2751
  const _logger = $logger.child({ context: "createSshMcpServer" });
3025
- const config = loadConfig(options?.configPath);
2752
+ const profile = loadHostProfile();
3026
2753
  const sessions = new SshSessionManager();
3027
2754
  sessions.startIdleSweeper();
3028
2755
  const server = new McpServer(
@@ -3035,28 +2762,15 @@ function createSshMcpServer(options) {
3035
2762
  }
3036
2763
  );
3037
2764
  const context = {
3038
- config,
3039
- sessions,
3040
- reloadConfig() {
3041
- const reloaded = loadConfig(options?.configPath);
3042
- context.config = reloaded;
3043
- return reloaded;
3044
- }
2765
+ profile,
2766
+ sessions
3045
2767
  };
3046
2768
  registerAllTools(server, context);
3047
- _logger.info("mcp server built", {
3048
- profile_count: config.profiles.size,
3049
- config_source: config.sourceLabel
3050
- });
2769
+ _logger.info("mcp server built", { profile: profile?.name ?? null });
3051
2770
  return {
3052
2771
  server,
3053
2772
  sessions,
3054
- get config() {
3055
- return context.config;
3056
- },
3057
- reloadConfig() {
3058
- return context.reloadConfig();
3059
- },
2773
+ profile,
3060
2774
  shutdown() {
3061
2775
  sessions.stopIdleSweeper();
3062
2776
  sessions.closeAll();
@@ -3064,6 +2778,6 @@ function createSshMcpServer(options) {
3064
2778
  };
3065
2779
  }
3066
2780
 
3067
- export { $logger, CONFIG_INPUT_SCHEMA, CONFIG_PATH_ENV, GuardRejectionError, HOST_INPUT_SCHEMA, LOG_LEVEL_ENV, POLICY_INPUT_SCHEMA, SERVER_NAME, SERVER_VERSION, SshConnection, SshSession, SshSessionManager, analyzeSegment, canonicalizeCommand, createSshMcpServer, downloadFile, extractSubstitutions, inspectCommand, inspectLocalPath, inspectRemotePath, inspectWorkingDirectory, listRemoteDirectory, loadConfig, normalizeCommandText, readRemoteFile, registerAllTools, splitCommandSegments, stripQuotedText, toHostSummary, uploadFile, writeRemoteFile };
2781
+ export { $logger, GuardRejectionError, LOG_LEVEL_ENV, SERVER_NAME, SERVER_VERSION, SINGLE_HOST_ENV, SshConnection, SshSession, SshSessionManager, analyzeSegment, canonicalizeCommand, createSshMcpServer, downloadFile, extractSubstitutions, inspectCommand, inspectLocalPath, inspectRemotePath, inspectWorkingDirectory, listRemoteDirectory, loadHostProfile, normalizeCommandText, readRemoteFile, registerAllTools, splitCommandSegments, stripQuotedText, toHostSummary, uploadFile, writeRemoteFile };
3068
2782
  //# sourceMappingURL=index.js.map
3069
2783
  //# sourceMappingURL=index.js.map