@yawlabs/ssh-mcp 0.12.0 → 0.13.0

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.
Files changed (2) hide show
  1. package/bin/ssh-mcp.mjs +169 -0
  2. package/package.json +3 -2
@@ -0,0 +1,169 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Runtime launcher for @yawlabs/ssh-mcp.
4
+ *
5
+ * Prefers the oam runtime (https://oamjs.org) and falls back to the Node
6
+ * process already running this file.
7
+ *
8
+ *
9
+ * WHY THE FALLBACK COSTS NOTHING
10
+ * npm has already started Node to run this launcher, so falling back is a
11
+ * plain `import()` of the server into THIS process: no extra spawn, no extra
12
+ * startup, byte-identical to invoking dist/index.js directly. Discovery is
13
+ * stat-only -- never a subprocess -- so the miss case stays sub-millisecond.
14
+ *
15
+ * WHAT THE OAM PATH COSTS
16
+ * Reaching oam through an npm `bin` means Node boots first and oam boots
17
+ * second, so the launcher is slower than either runtime alone. Measured on
18
+ * npmjs-mcp (windows-arm64, n=12 medians, spawn to first MCP initialize):
19
+ * oam 116ms, node 172ms, launcher 243ms. oam is the fastest runtime and the
20
+ * launcher is the slowest path -- it exists for `npx` convenience.
21
+ *
22
+ * For an MCP host config, point straight at oam and skip this file:
23
+ * { "command": "oam", "args": ["run", "<abs>/dist/index.js"] }
24
+ *
25
+ * SELECTION
26
+ * SSH_MCP_RUNTIME=oam require oam; fail loudly if it is missing
27
+ * SSH_MCP_RUNTIME=node never use oam
28
+ * SSH_MCP_RUNTIME=auto prefer oam, silently fall back (default)
29
+ * OAM_BIN=/path/to/oam explicit binary, checked before any discovery
30
+ */
31
+
32
+ import { spawn } from "node:child_process";
33
+ import { existsSync } from "node:fs";
34
+ import { constants, homedir } from "node:os";
35
+ import { delimiter, join } from "node:path";
36
+ import { fileURLToPath } from "node:url";
37
+
38
+ // Two forms, deliberately. `import()` on Windows REJECTS a bare `C:\...` path
39
+ // with ERR_UNSUPPORTED_ESM_URL_SCHEME (it reads `c:` as a protocol), so the
40
+ // in-process fallback must use the file:// URL. spawn() needs a real path.
41
+ const SERVER_URL = new URL("../dist/index.js", import.meta.url);
42
+ const SERVER_ENTRY = fileURLToPath(SERVER_URL);
43
+ const isWin = process.platform === "win32";
44
+ const exe = isWin ? "oam.exe" : "oam";
45
+
46
+ /** Locate an oam binary, or null. Every branch is a stat, never a subprocess. */
47
+ function findOam() {
48
+ // 1. Explicit override wins and is never second-guessed.
49
+ const override = process.env.OAM_BIN;
50
+ if (override) return existsSync(override) ? override : null;
51
+
52
+ // 2. Installed locations, BEFORE PATH. Someone who develops oam itself
53
+ // usually has oam/target/release on PATH, and a build directory is the
54
+ // wrong thing for a user-facing launcher to bind to: cargo replaces the
55
+ // binary underneath running processes, and the dev build is not the
56
+ // release the user installed. Preferring the installed copy makes the
57
+ // default path "what a normal user has", and OAM_BIN remains the way to
58
+ // point deliberately at a dev build.
59
+ //
60
+ // Both forms are checked on Windows: the installer defaults to
61
+ // %LOCALAPPDATA%oamin there, but oam's docs name ~/.oam/bin first and
62
+ // OAM_INSTALL_DIR can pick either, so checking one silently misses a real
63
+ // install.
64
+ const installed = [join(homedir(), ".oam", "bin", exe)];
65
+ if (isWin) {
66
+ installed.unshift(join(process.env.LOCALAPPDATA ?? join(homedir(), "AppData", "Local"), "oam", "bin", exe));
67
+ }
68
+ for (const candidate of installed) {
69
+ if (existsSync(candidate)) return candidate;
70
+ }
71
+
72
+ // 3. PATH, resolved manually rather than by spawning `which`/`where`, which
73
+ // would cost a subprocess on every launch just to decide whether to spawn.
74
+ const pathExt = isWin ? (process.env.PATHEXT ?? ".EXE").split(";").filter(Boolean) : [""];
75
+ for (const dir of (process.env.PATH ?? "").split(delimiter)) {
76
+ if (!dir) continue;
77
+ for (const ext of isWin ? pathExt : [""]) {
78
+ const candidate = join(dir, isWin ? `oam${ext.toLowerCase()}` : "oam");
79
+ if (existsSync(candidate)) return candidate;
80
+ }
81
+ }
82
+
83
+ return null;
84
+ }
85
+
86
+ /** Run the server in THIS process. The zero-overhead fallback. */
87
+ async function runInProcess() {
88
+ // A server may gate its bootstrap on being the process ENTRY POINT --
89
+ // `import.meta.url === pathToFileURL(process.argv[1]).href` -- so that its own
90
+ // test file can import the module for unit tests without connecting a stdio
91
+ // transport. aws-mcp does exactly this. Importing the server here would leave
92
+ // argv[1] pointing at THIS launcher, the guard would read false, and the
93
+ // server would load but never serve: the MCP handshake just hangs.
94
+ //
95
+ // Point argv[1] at the server first, so the in-process path is
96
+ // indistinguishable from having executed the file directly. The spawn path
97
+ // needs no equivalent -- there argv[1] is already the server.
98
+ process.argv[1] = SERVER_ENTRY;
99
+ await import(SERVER_URL.href);
100
+ }
101
+
102
+ const mode = (process.env.SSH_MCP_RUNTIME ?? "auto").toLowerCase();
103
+
104
+ if (mode === "node") {
105
+ await runInProcess();
106
+ } else {
107
+ const oam = findOam();
108
+
109
+ if (!oam) {
110
+ if (mode === "oam") {
111
+ // Explicitly demanded, so this is a real misconfiguration. writeSync
112
+ // because stderr is async for TTYs/pipes on Windows and process.exit
113
+ // truncates pending writes.
114
+ const { writeSync } = await import("node:fs");
115
+ writeSync(
116
+ 2,
117
+ "ssh-mcp: SSH_MCP_RUNTIME=oam but no oam binary was found.\n" +
118
+ "Install from https://oamjs.org, set OAM_BIN=/path/to/oam, or use SSH_MCP_RUNTIME=node.\n",
119
+ );
120
+ process.exit(1);
121
+ }
122
+ await runInProcess();
123
+ } else {
124
+ // `--` separates oam's own flags from the script's argv, so `ssh-mcp
125
+ // --version` and any host-supplied flags survive the hop unchanged.
126
+ const child = spawn(oam, ["run", SERVER_ENTRY, "--", ...process.argv.slice(2)], {
127
+ // inherit keeps the SAME fds, so MCP's newline-delimited JSON framing on
128
+ // stdin/stdout is untouched and the host's stdin-close still reaches the
129
+ // server's shutdown path.
130
+ stdio: "inherit",
131
+ env: process.env,
132
+ windowsHide: true,
133
+ });
134
+
135
+ // If oam cannot be executed at all (deleted between the stat and the spawn,
136
+ // wrong arch, permission), fall back rather than failing the whole server.
137
+ // `spawned` prevents falling back AFTER the child started, which would
138
+ // double-start the server on the same stdio.
139
+ let spawned = false;
140
+ child.on("spawn", () => {
141
+ spawned = true;
142
+ });
143
+ child.on("error", (err) => {
144
+ if (spawned) return;
145
+ if (mode === "oam") {
146
+ process.stderr.write(`ssh-mcp: failed to launch oam (${err.message})\n`);
147
+ process.exit(1);
148
+ }
149
+ void runInProcess();
150
+ });
151
+
152
+ // Forward termination so the server's own shutdown path runs in the child
153
+ // rather than the child being orphaned. No-op on Windows, harmless to add.
154
+ for (const sig of ["SIGINT", "SIGTERM"]) {
155
+ process.on(sig, () => {
156
+ if (!child.killed) child.kill(sig);
157
+ });
158
+ }
159
+
160
+ child.on("exit", (code, signal) => {
161
+ // Mirror the child's fate: a signal death becomes 128+n so callers see a
162
+ // conventional shell exit status rather than a bare 0.
163
+ if (signal) {
164
+ process.exit(128 + (constants.signals[signal] ?? 15));
165
+ }
166
+ process.exit(code ?? 0);
167
+ });
168
+ }
169
+ }
package/package.json CHANGED
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "name": "@yawlabs/ssh-mcp",
3
- "version": "0.12.0",
3
+ "version": "0.13.0",
4
4
  "mcpName": "io.github.YawLabs/ssh-mcp",
5
5
  "description": "MCP server for SSH operations with built-in diagnostics",
6
6
  "type": "module",
7
7
  "bin": {
8
- "ssh-mcp": "dist/index.js"
8
+ "ssh-mcp": "bin/ssh-mcp.mjs"
9
9
  },
10
10
  "exports": {
11
11
  ".": {
@@ -14,6 +14,7 @@
14
14
  }
15
15
  },
16
16
  "files": [
17
+ "bin/ssh-mcp.mjs",
17
18
  "dist",
18
19
  "LICENSE",
19
20
  "README.md"