@yawlabs/ssh-mcp 0.17.3 → 0.18.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -11
- package/bin/ssh-mcp.mjs +114 -17
- package/dist/index.js +138 -33
- package/dist/policy.d.ts +1 -1
- package/dist/server.d.ts +12 -5
- package/dist/server.js +142 -33
- package/dist/ssh.d.ts +38 -2
- package/package.json +4 -2
package/README.md
CHANGED
|
@@ -87,8 +87,8 @@ Tools that fix your local SSH setup so everything else — git, deploys, tunnels
|
|
|
87
87
|
|
|
88
88
|
| Tool | Description |
|
|
89
89
|
|------|-------------|
|
|
90
|
-
| `ssh_exec` | Execute a command on a remote host. Returns
|
|
91
|
-
| `ssh_read_file` | Read a file from a remote host via SFTP. |
|
|
90
|
+
| `ssh_exec` | Execute a command on a remote host. Returns up to three content blocks, status first: `[exit code: N]` (plus `[signal: NAME]` with `code: -1` when the channel closed signal-only, and a flag when a stream hit the 10 MB cap), then `[stderr]`, then `[stdout]`. Status leads so a proxy that cuts long results — Yaw MCP keeps about 100 KB by default — never drops the exit code. Optional `env` param sets per-call environment variables (POSIX-safe prefix, works regardless of sshd's `AcceptEnv`). Subject to [command policy](#command-policy) if configured. |
|
|
91
|
+
| `ssh_read_file` | Read a file from a remote host via SFTP (whole file up to 10 MB). Optional `offset` and `length` (bytes) read one page and return a `[bytes A-B of SIZE; next offset: N]` status block before the text — use them for a large file, or behind a proxy that caps results. |
|
|
92
92
|
| `ssh_write_file` | Write content to a file on a remote host via SFTP. |
|
|
93
93
|
| `ssh_upload` | Upload a local file to a remote host via SFTP. |
|
|
94
94
|
| `ssh_download` | Download a file from a remote host to local filesystem. |
|
|
@@ -137,11 +137,11 @@ All remote operations verify the server's host key against `~/.ssh/known_hosts`:
|
|
|
137
137
|
|
|
138
138
|
- **Known host, key matches** — accept.
|
|
139
139
|
- **Known host, key changed** — reject (MITM protection). The rejection message distinguishes a genuine key mismatch from "the server offered a key type your `known_hosts` entry doesn't cover", so a missing ed25519 line doesn't read as an attack.
|
|
140
|
-
- **Unknown host** — accept, unless `
|
|
140
|
+
- **Unknown host** — accept, unless strict mode is on (`SSH_MCP_STRICT_HOSTKEYS=1`).
|
|
141
141
|
|
|
142
142
|
**That last branch is trust-always, not TOFU.** Real trust-on-first-use pins the key it saw the first time and rejects a change afterwards. The connection path never writes to `known_hosts` — no tool adds an entry as a side effect of connecting — so connecting pins nothing: *every* connection to a host absent from `known_hosts` is a "first" use and is accepted, including one where an attacker swapped the key since your last call. Only hosts put into `known_hosts` out of band get mismatch protection — by you, by `ssh-keyscan`, or by `ssh_known_hosts_fix`, the one tool here that does write the file. Call it explicitly to add an entry so future changes are caught.
|
|
143
143
|
|
|
144
|
-
For stricter environments, set `
|
|
144
|
+
For stricter environments, set `SSH_MCP_STRICT_HOSTKEYS=1` (or `true`, case-insensitive) to reject unknown hosts. Add them explicitly with `ssh_known_hosts_fix` first. The older name `SSH_MCP_STRICT_HOST_KEY` still works and accepts the same values, but prefer the new one: credential scanners such as Yaw MCP's doctor treat any name with a `_KEY` segment as a secret, and flag the old name as a plaintext credential.
|
|
145
145
|
|
|
146
146
|
The diagnostic tools (`ssh_test`, `ssh_diagnose`) use `StrictHostKeyChecking=no` for their probe commands. Those probes only run `echo SSH_OK` — no credentials or data pass through — so the relaxed setting is safe for connectivity testing. Real operations always go through the `hostVerifier`.
|
|
147
147
|
|
|
@@ -207,6 +207,8 @@ If you don't trust the agent's `env` values at all, the simplest mitigation is t
|
|
|
207
207
|
|
|
208
208
|
On Windows, ssh-mcp uses the OpenSSH Authentication Agent's `\\.\pipe\openssh-ssh-agent` named pipe automatically when `SSH_AUTH_SOCK` is not set. No `SSH_AUTH_SOCK` needed — just make sure the OpenSSH agent service is running.
|
|
209
209
|
|
|
210
|
+
On the [oam](https://oamjs.org) runtime, the launcher serves only on oam 0.19.1 or newer. Agent auth needs a `net` that can dial a named pipe or a Unix socket (that is how ssh2 reaches the agent), which older oam releases could not do. On oam 0.19.1 (Windows) the MCP handshake, `ssh_config_lookup`, `ssh_key_list` and `ssh_agent_ensure` were verified through the launcher, and ssh2's own agent client was verified dialing a named pipe and listing identities. An agent-only `ssh_exec` end to end has so far been verified on Node only. If agent auth fails on oam, set `SSH_MCP_RUNTIME=node` to rule the runtime out.
|
|
211
|
+
|
|
210
212
|
`ssh_agent_ensure` and `ssh_diagnose` probe that pipe and tell you if the service is down. Remote operations do *not*: they assume the pipe and let the connection fail on its own if the agent isn't there. That is why a stopped agent service shows up as an auth failure rather than an "agent not running" error until you run the diagnostic tools.
|
|
211
213
|
|
|
212
214
|
## Authentication
|
|
@@ -326,26 +328,28 @@ for (const check of report.checks) {
|
|
|
326
328
|
|
|
327
329
|
## Runtime selection
|
|
328
330
|
|
|
329
|
-
The `ssh-mcp` command — what `npx @yawlabs/ssh-mcp` runs — is a small launcher. It prefers the [oam](https://oamjs.org) runtime and otherwise runs the server on Node, so oam is optional. Its minimum is the **latest oam release, currently 0.
|
|
331
|
+
The `ssh-mcp` command — what `npx @yawlabs/ssh-mcp` runs — is a small launcher. It prefers the [oam](https://oamjs.org) runtime and otherwise runs the server on Node, so oam is optional. Its minimum is the **latest oam release, currently 0.19.1**, and it never serves on an older oam.
|
|
330
332
|
|
|
331
333
|
Two environment variables control the choice; set them in your MCP client's `env` block:
|
|
332
334
|
|
|
333
335
|
- `SSH_MCP_RUNTIME` — which runtime to use. Case-insensitive; any value other than `oam` or `node` behaves as `auto`.
|
|
334
|
-
- `auto` (default) — use the newest oam it can find at 0.
|
|
336
|
+
- `auto` (default) — use the newest oam it can find at 0.19.1 or newer, otherwise run on Node.
|
|
335
337
|
- `oam` — require oam. Fails loudly: if no usable oam is found, the launcher prints what it found and why each one was passed over to stderr, and exits with status 1 instead of starting the server.
|
|
336
338
|
- `node` — always Node. The launcher does not look for oam, and `OAM_BIN` is ignored.
|
|
337
|
-
- `OAM_BIN` — path to an oam binary to use in preference to discovery, when it is 0.
|
|
339
|
+
- `OAM_BIN` — path to an oam binary to use in preference to discovery, when it is 0.19.1 or newer. If it does not exist, is older, will not run, or does not answer `--version` with oam's own `oam x.y.z` banner (another program's version, such as Node's `v22.x.y`, does not count), the launcher says so on stderr and carries on with discovery.
|
|
338
340
|
|
|
339
|
-
Discovery looks in `%LOCALAPPDATA%\oam\bin` (Windows only), then `~/.oam/bin`, then every directory on `PATH`; asks every oam binary it finds for its version; and uses the newest one at 0.
|
|
341
|
+
Discovery looks in `$OAM_INSTALL_DIR` (when set — the oam installer's install target), then `%LOCALAPPDATA%\oam\bin` (Windows only), then `~/.oam/bin`, then every directory on `PATH`; asks every oam binary it finds for its version, counting only an answer that carries oam's own `oam x.y.z` banner; and uses the newest one at 0.19.1 or newer. On a tie the one found first wins, so an installed copy beats one on `PATH`. On Windows only `oam.exe` counts: an `oam.cmd`/`oam.bat` shim on `PATH` is never run, and is named on stderr when no usable oam is found.
|
|
340
342
|
|
|
341
|
-
By default an unusable oam is not an error. When Node starts the launcher (`npx` or a global install, run directly by your MCP client), `auto` mode falls back to Node — silently when there was nothing to find, or with a note on stderr naming each oam (and `OAM_BIN`) it passed over and why: older than 0.
|
|
343
|
+
By default an unusable oam is not an error. When Node starts the launcher (`npx` or a global install, run directly by your MCP client), `auto` mode falls back to Node — silently when there was nothing to find, or with a note on stderr naming each oam (and `OAM_BIN`) it passed over and why: older than 0.19.1, not runnable, or a shim. A chosen oam that then fails to start is named on stderr too. When an older oam starts the launcher, the handoff to Node is always noted on stderr (see below). With `SSH_MCP_RUNTIME=oam`, each of these cases exits with status 1 instead.
|
|
342
344
|
|
|
343
345
|
When the launcher is itself started by oam (`oam run <path>/bin/ssh-mcp.mjs`, which is how Yaw MCP starts an `npx @yawlabs/ssh-mcp` entry when a recent oam is installed):
|
|
344
346
|
|
|
345
|
-
- On oam 0.
|
|
347
|
+
- On oam 0.19.1 or newer it runs the server on that oam, in the same process. Nothing is discovered or spawned, `OAM_BIN` is not read, and `SSH_MCP_RUNTIME=oam` counts it as the oam it requires.
|
|
346
348
|
- On an older oam it never runs the server there. It hands the server off to the newest usable oam it can find, or to Node on `PATH`, or exits with status 1 when there is neither (`SSH_MCP_RUNTIME=oam` exits with status 1 rather than handing off to Node).
|
|
347
349
|
- With `SSH_MCP_RUNTIME=node` it hands the server off to Node on `PATH`, whatever the oam version.
|
|
348
350
|
|
|
351
|
+
Whenever an oam host starts a child (the handoffs above), the launcher removes `--permission` and `--allow-*` flags from the child's inherited `NODE_OPTIONS`. oam (since 0.18.0) adds its own permission flags to every child's `NODE_OPTIONS`, and Node refuses some of them there (`--allow-net is not allowed in NODE_OPTIONS`, exit 9); this server is not sandboxed, so there is nothing to pass on. An oam that was itself started with `--permission` re-adds the flags inside `spawn()`, which the launcher cannot undo, so do not start it that way.
|
|
352
|
+
|
|
349
353
|
For example, to always run on Node:
|
|
350
354
|
|
|
351
355
|
```json
|
|
@@ -366,7 +370,7 @@ On Windows, add the same `env` block to the `cmd /c` form from [Quick start](#qu
|
|
|
366
370
|
|
|
367
371
|
- Node.js 18+
|
|
368
372
|
- SSH client installed (for diagnostics and environment management)
|
|
369
|
-
- Optional: [oam](https://oamjs.org) 0.
|
|
373
|
+
- Optional: [oam](https://oamjs.org) 0.19.1+ — see [Runtime selection](#runtime-selection)
|
|
370
374
|
|
|
371
375
|
## License
|
|
372
376
|
|
package/bin/ssh-mcp.mjs
CHANGED
|
@@ -40,7 +40,7 @@
|
|
|
40
40
|
* named on stderr, and discovery carries on. It used to stop everything: a typo
|
|
41
41
|
* in OAM_BIN meant Node, with no hint why. The discovered binaries that were
|
|
42
42
|
* passed over, and any .cmd/.bat shim on PATH, are named only when NO usable
|
|
43
|
-
* oam is found -- with 0.9.0 installed and 0.
|
|
43
|
+
* oam is found -- with 0.9.0 installed and 0.19.1 on PATH, stderr stays empty.
|
|
44
44
|
*
|
|
45
45
|
* ALREADY RUNNING ON OAM
|
|
46
46
|
* A host can resolve this package's `bin` and launch `oam run <this file>`
|
|
@@ -77,15 +77,41 @@
|
|
|
77
77
|
* wired up here.
|
|
78
78
|
*
|
|
79
79
|
* MINIMUM OAM VERSION
|
|
80
|
-
* The latest oam release, 0.
|
|
80
|
+
* The latest oam release, 0.19.1 -- bump OAM_MIN when oam ships a newer one.
|
|
81
81
|
* Only the current oam is used and verified; an older one is passed over.
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
*
|
|
82
|
+
* Verified on 0.19.1 through this launcher (`oam run bin/ssh-mcp.mjs`): MCP
|
|
83
|
+
* initialize, tools/list and tools/call on ssh_key_list, ssh_config_lookup and
|
|
84
|
+
* ssh_agent_ensure, plus ssh2's agent client dialing a Windows named pipe.
|
|
85
|
+
*
|
|
86
|
+
* The floor is not cosmetic. Two things this server does were first fixed in
|
|
87
|
+
* oam 0.18.0, so anyone taking it below that has to answer for both:
|
|
88
|
+
*
|
|
89
|
+
* 1. An explicit child `env` that DELETES a variable. probeAgent
|
|
90
|
+
* (src/env.ts:85-90) runs `ssh-add -l` with SSH_AUTH_SOCK removed, so
|
|
91
|
+
* Windows ssh-add falls back to the OpenSSH named pipe instead of a stale
|
|
92
|
+
* Unix-style socket. Up to 0.17.x oam laid an explicit `env` OVER its
|
|
93
|
+
* startup environment, so the deleted SSH_AUTH_SOCK came back and ssh-add
|
|
94
|
+
* probed the wrong agent. 0.18.0 makes an explicit `env` replace it, as
|
|
95
|
+
* Node does (oam CHANGELOG 0.18.0, B4).
|
|
96
|
+
* 2. `net.connect` to a Windows named pipe or a Unix socket path.
|
|
97
|
+
* resolveConfig (src/ssh.ts:581-584) hands ssh2 SSH_AUTH_SOCK, or on Windows
|
|
98
|
+
* `\\.\pipe\openssh-ssh-agent`, and ssh2 dials it with
|
|
99
|
+
* `new Socket().connect(path)` for every agent-backed connection. Up to
|
|
100
|
+
* 0.17.x oam could not dial either (oam CHANGELOG 0.18.0, F1).
|
|
101
|
+
*
|
|
102
|
+
* What moving to 0.19.1 changed here: oam used to start EVERY child with
|
|
103
|
+
* CREATE_NO_WINDOW and ignore `windowsHide`; 0.19.1 honours `windowsHide` as
|
|
104
|
+
* Node does, so by default a child shares the console (oam CHANGELOG 0.19.1).
|
|
105
|
+
* runArgs (src/diagnose.ts) and runArgsWithEnv (src/env.ts) therefore pass
|
|
106
|
+
* `windowsHide: true` themselves, or every ssh / ssh-add / ssh-keygen probe could
|
|
107
|
+
* flash a console window on Windows.
|
|
108
|
+
*
|
|
109
|
+
* Background, long before the current floor: before 0.9.0
|
|
110
|
+
* `child_process.execFile` ran its arguments through a SHELL, `exec` accepted
|
|
111
|
+
* `timeout` and ignored it, `spawnSync` truncated at `maxBuffer` while
|
|
112
|
+
* reporting success, and `stdio: 'inherit'`/`'ignore'` both behaved as
|
|
113
|
+
* `'pipe'`. This server shells out to ssh, ssh-add and ssh-keygen on its main
|
|
114
|
+
* paths, so those were reachable bugs rather than theoretical ones.
|
|
89
115
|
*
|
|
90
116
|
* SELECTION
|
|
91
117
|
* SSH_MCP_RUNTIME=auto newest usable oam, else Node (default)
|
|
@@ -103,8 +129,14 @@ import { constants, homedir } from "node:os";
|
|
|
103
129
|
import { delimiter, join } from "node:path";
|
|
104
130
|
import { fileURLToPath } from "node:url";
|
|
105
131
|
|
|
106
|
-
/**
|
|
107
|
-
|
|
132
|
+
/**
|
|
133
|
+
* Oldest oam this server runs on: the latest release, the one it is verified on.
|
|
134
|
+
* It must also keep 0.18.0's explicit child `env` that replaces the environment
|
|
135
|
+
* (needed by probeAgent, src/env.ts) and `net` dialing named pipes and Unix
|
|
136
|
+
* sockets (needed by ssh2's agent client, src/ssh.ts). See MINIMUM OAM VERSION
|
|
137
|
+
* above.
|
|
138
|
+
*/
|
|
139
|
+
const OAM_MIN = [0, 19, 1];
|
|
108
140
|
|
|
109
141
|
/**
|
|
110
142
|
* Bound on each `oam --version` probe. A healthy oam answers in milliseconds;
|
|
@@ -141,8 +173,12 @@ function pathKey(p) {
|
|
|
141
173
|
* usually has oam/target/release on PATH, and cargo replaces that binary
|
|
142
174
|
* underneath running processes; the installed copy is the release the user
|
|
143
175
|
* actually installed. Both forms are checked on Windows: the installer defaults
|
|
144
|
-
* to %LOCALAPPDATA%\oam\bin there, but oam's docs name ~/.oam/bin first
|
|
145
|
-
*
|
|
176
|
+
* to %LOCALAPPDATA%\oam\bin there, but oam's docs name ~/.oam/bin first.
|
|
177
|
+
*
|
|
178
|
+
* OAM_INSTALL_DIR, when set, is searched before both. It is the installer's
|
|
179
|
+
* install target (oam docs/cli-reference.md), so an oam put somewhere custom and
|
|
180
|
+
* left off PATH is still found -- this comment used to say it "can pick either"
|
|
181
|
+
* default while the code never read it.
|
|
146
182
|
*
|
|
147
183
|
* Windows: `.exe` ONLY -- deliberately narrower than PATHEXT. Node refuses to
|
|
148
184
|
* run a .cmd/.bat through execFile/spawn without `shell: true` (EINVAL, and for
|
|
@@ -156,6 +192,7 @@ function discoverOamPaths() {
|
|
|
156
192
|
if (isWin) {
|
|
157
193
|
installed.unshift(join(process.env.LOCALAPPDATA ?? join(homedir(), "AppData", "Local"), "oam", "bin", exe));
|
|
158
194
|
}
|
|
195
|
+
if (process.env.OAM_INSTALL_DIR) installed.unshift(join(process.env.OAM_INSTALL_DIR, exe));
|
|
159
196
|
const onPath = (process.env.PATH ?? "")
|
|
160
197
|
.split(delimiter)
|
|
161
198
|
.filter(Boolean)
|
|
@@ -201,7 +238,7 @@ async function errSync(message) {
|
|
|
201
238
|
* A pre-release suffix (0.9.0-rc.1) truncates to its base version.
|
|
202
239
|
*
|
|
203
240
|
* Shared by the two places a version is read -- a discovered binary's
|
|
204
|
-
* `oam --version`
|
|
241
|
+
* `oam --version` banner ("oam 0.15.1", via oamBanner) and the host's own
|
|
205
242
|
* `process.versions.oam` ("0.15.1") -- so they cannot disagree about what a
|
|
206
243
|
* version string means, or which floor it has to clear.
|
|
207
244
|
*/
|
|
@@ -210,6 +247,19 @@ function parseVersion(text) {
|
|
|
210
247
|
return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;
|
|
211
248
|
}
|
|
212
249
|
|
|
250
|
+
/**
|
|
251
|
+
* `oam --version` output -> [major, minor, patch], or null unless it carries
|
|
252
|
+
* oam's own banner ("oam 0.19.1" at the start of a line). Any x.y.z is NOT
|
|
253
|
+
* enough: an OAM_BIN (or an oam.exe on disk) that is really another program --
|
|
254
|
+
* node prints "v22.22.2" -- would clear the floor, and the launcher would then
|
|
255
|
+
* spawn `<it> run dist/index.js`, which dies with no Node fallback because the
|
|
256
|
+
* spawn itself succeeded. process.versions.oam needs no banner: only oam sets it.
|
|
257
|
+
*/
|
|
258
|
+
function oamBanner(text) {
|
|
259
|
+
const m = /^oam\s+(\d+\.\d+\.\d+)/m.exec(text);
|
|
260
|
+
return m ? parseVersion(m[1]) : null;
|
|
261
|
+
}
|
|
262
|
+
|
|
213
263
|
/** `oam --version` -> [major, minor, patch], or null when it cannot be read. */
|
|
214
264
|
function oamVersion(cmd) {
|
|
215
265
|
try {
|
|
@@ -219,7 +269,7 @@ function oamVersion(cmd) {
|
|
|
219
269
|
timeout: VERSION_PROBE_TIMEOUT_MS,
|
|
220
270
|
windowsHide: true,
|
|
221
271
|
});
|
|
222
|
-
return
|
|
272
|
+
return oamBanner(out);
|
|
223
273
|
} catch {
|
|
224
274
|
// Not executable, wrong arch, wedged, or deleted since the stat. Caller degrades.
|
|
225
275
|
return null;
|
|
@@ -405,6 +455,48 @@ const fallbackFailed = (e) => {
|
|
|
405
455
|
process.exitCode = 1;
|
|
406
456
|
};
|
|
407
457
|
|
|
458
|
+
/**
|
|
459
|
+
* A permission flag oam may have put in NODE_OPTIONS: `--permission` (and the
|
|
460
|
+
* older `--experimental-permission`) or any `--allow-*` grant, with or without
|
|
461
|
+
* an `=value`.
|
|
462
|
+
*/
|
|
463
|
+
const PERMISSION_FLAG = /^--(?:experimental-)?permission$|^--allow-[a-z0-9-]+(?:=.*)?$/;
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* `env` with oam's permission flags removed from NODE_OPTIONS, or `env` itself
|
|
467
|
+
* when there is nothing to remove.
|
|
468
|
+
*
|
|
469
|
+
* oam 0.18.0 appends its own `--permission` / `--allow-*` flags to every child's
|
|
470
|
+
* NODE_OPTIONS "for any program" (oam CHANGELOG 0.18.0, Permissions), so a
|
|
471
|
+
* process started under a sandboxed oam carries them in its environment. Node
|
|
472
|
+
* refuses some of them there -- measured on Node 22:
|
|
473
|
+
* `NODE_OPTIONS="--permission --allow-net" node` exits 9 with "--allow-net is
|
|
474
|
+
* not allowed in NODE_OPTIONS" -- and this launcher has no sandbox to pass on
|
|
475
|
+
* (see NO SANDBOX HERE), so the tokens are dropped before a child is started.
|
|
476
|
+
*
|
|
477
|
+
* Only the INHERITED copy can be removed here. When THIS process was itself
|
|
478
|
+
* started as `oam --permission ...`, oam re-appends those flags from its own
|
|
479
|
+
* execArgv inside spawn(), after this env is built -- measured on oam 0.18.0:
|
|
480
|
+
* the Node child still exits 9 with this env. That host is outside what this
|
|
481
|
+
* launcher supports, since the server needs unrestricted net and child-process
|
|
482
|
+
* grants anyway.
|
|
483
|
+
*
|
|
484
|
+
* Tokens are split on whitespace with double-quoted spans kept whole, which is
|
|
485
|
+
* how Node itself reads NODE_OPTIONS. Pure, so src/tests/launcher.test.ts can
|
|
486
|
+
* extract and test it without booting a runtime.
|
|
487
|
+
*/
|
|
488
|
+
function stripPermissionFlags(env) {
|
|
489
|
+
const raw = env.NODE_OPTIONS;
|
|
490
|
+
if (!raw) return env;
|
|
491
|
+
const tokens = raw.match(/(?:[^\s"]+|"[^"]*")+/g) ?? [];
|
|
492
|
+
const kept = tokens.filter((t) => !PERMISSION_FLAG.test(t.replaceAll('"', "")));
|
|
493
|
+
if (kept.length === tokens.length) return env;
|
|
494
|
+
const next = { ...env };
|
|
495
|
+
if (kept.length === 0) delete next.NODE_OPTIONS;
|
|
496
|
+
else next.NODE_OPTIONS = kept.join(" ");
|
|
497
|
+
return next;
|
|
498
|
+
}
|
|
499
|
+
|
|
408
500
|
/**
|
|
409
501
|
* Spawn the server in a child runtime and mirror its lifetime.
|
|
410
502
|
*
|
|
@@ -428,7 +520,9 @@ async function launchChild(cmd, args, onLaunchFailed) {
|
|
|
428
520
|
// server's shutdown path. Piping preserves both as well: bytes are copied
|
|
429
521
|
// unchanged, and stdin's end propagates to the child.
|
|
430
522
|
stdio: piped ? ["pipe", "pipe", "pipe"] : "inherit",
|
|
431
|
-
|
|
523
|
+
// On an oam host, drop the permission flags oam put in NODE_OPTIONS; see
|
|
524
|
+
// stripPermissionFlags. A Node host's env passes through untouched.
|
|
525
|
+
env: process.versions.oam !== undefined ? stripPermissionFlags(process.env) : process.env,
|
|
432
526
|
windowsHide: true,
|
|
433
527
|
});
|
|
434
528
|
} catch (err) {
|
|
@@ -592,7 +686,10 @@ const hostOam = process.versions.oam;
|
|
|
592
686
|
const plan = runtimePlan({ mode, hostOam });
|
|
593
687
|
|
|
594
688
|
if (plan === "in-process") {
|
|
595
|
-
|
|
689
|
+
// Guarded like every other in-process fallback: a broken install (missing
|
|
690
|
+
// dist/index.js) must print this launcher's diagnostic, not a raw Node stack
|
|
691
|
+
// trace from the bare import().
|
|
692
|
+
await runInProcess().catch(fallbackFailed);
|
|
596
693
|
} else if (plan === "handoff-node") {
|
|
597
694
|
const belowFloor = !atLeast(parseVersion(hostOam), OAM_MIN);
|
|
598
695
|
await handOffToNode(belowFloor ? `this process is oam ${hostOam}, older than ${OAM_MIN.join(".")}` : "");
|
package/dist/index.js
CHANGED
|
@@ -46,7 +46,12 @@ function isValidHostname(host) {
|
|
|
46
46
|
}
|
|
47
47
|
function runArgs(cmd, args) {
|
|
48
48
|
try {
|
|
49
|
-
const stdout = execFileSync(cmd, args, {
|
|
49
|
+
const stdout = execFileSync(cmd, args, {
|
|
50
|
+
encoding: "utf8",
|
|
51
|
+
timeout: 1e4,
|
|
52
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
53
|
+
windowsHide: true
|
|
54
|
+
});
|
|
50
55
|
return { stdout: stdout.trim(), ok: true };
|
|
51
56
|
} catch (e) {
|
|
52
57
|
const err = e;
|
|
@@ -441,8 +446,15 @@ function cachedKnownHostTypes(hosts, port) {
|
|
|
441
446
|
knownHostTypeCache.set(cacheKey, { at: now, types });
|
|
442
447
|
return types;
|
|
443
448
|
}
|
|
449
|
+
function isStrictHostKeyMode(env = process.env) {
|
|
450
|
+
const on = (v) => {
|
|
451
|
+
const t = (v ?? "").trim().toLowerCase();
|
|
452
|
+
return t === "1" || t === "true";
|
|
453
|
+
};
|
|
454
|
+
return on(env.SSH_MCP_STRICT_HOSTKEYS) || on(env.SSH_MCP_STRICT_HOST_KEY);
|
|
455
|
+
}
|
|
444
456
|
function buildHostVerifier(hosts, port, rejection) {
|
|
445
|
-
const strict =
|
|
457
|
+
const strict = isStrictHostKeyMode();
|
|
446
458
|
const label = hosts.join(" / ");
|
|
447
459
|
const remediationHost = unbracketHost(hosts[0]);
|
|
448
460
|
return (key) => {
|
|
@@ -452,7 +464,7 @@ function buildHostVerifier(hosts, port, rejection) {
|
|
|
452
464
|
if (strict) {
|
|
453
465
|
rejection.current = {
|
|
454
466
|
reason: "unknown-host-strict",
|
|
455
|
-
message: `no known_hosts entry for ${label}, and
|
|
467
|
+
message: `no known_hosts entry for ${label}, and strict mode (SSH_MCP_STRICT_HOSTKEYS=1) requires one. Add it: ssh-keyscan -H "${remediationHost}" >> ~/.ssh/known_hosts`
|
|
456
468
|
};
|
|
457
469
|
}
|
|
458
470
|
return !strict;
|
|
@@ -844,7 +856,7 @@ async function readFile(client, remotePath, maxBytes = DEFAULT_MAX_READ_BYTES) {
|
|
|
844
856
|
});
|
|
845
857
|
if (stats.size > maxBytes) {
|
|
846
858
|
throw new Error(
|
|
847
|
-
`File is ${(stats.size / 1024 / 1024).toFixed(1)} MB, exceeds ${(maxBytes / 1024 / 1024).toFixed(0)} MB limit.
|
|
859
|
+
`File is ${(stats.size / 1024 / 1024).toFixed(1)} MB, exceeds ${(maxBytes / 1024 / 1024).toFixed(0)} MB limit. Read it a page at a time with ssh_read_file's offset and length arguments (byte offsets; the file is ${stats.size} bytes).`
|
|
848
860
|
);
|
|
849
861
|
}
|
|
850
862
|
return await new Promise((resolve, reject) => {
|
|
@@ -857,6 +869,53 @@ async function readFile(client, remotePath, maxBytes = DEFAULT_MAX_READ_BYTES) {
|
|
|
857
869
|
sftp.end();
|
|
858
870
|
}
|
|
859
871
|
}
|
|
872
|
+
async function readFileRange(client, remotePath, offset, length, maxBytes = DEFAULT_MAX_READ_BYTES) {
|
|
873
|
+
if (!Number.isSafeInteger(offset) || offset < 0)
|
|
874
|
+
throw new Error(`offset must be a non-negative integer, got ${offset}`);
|
|
875
|
+
if (!Number.isSafeInteger(length) || length <= 0) throw new Error(`length must be a positive integer, got ${length}`);
|
|
876
|
+
if (length > maxBytes) {
|
|
877
|
+
throw new Error(
|
|
878
|
+
`length ${length} exceeds the ${(maxBytes / 1024 / 1024).toFixed(0)} MB limit for one read. Use a smaller length and page with offset.`
|
|
879
|
+
);
|
|
880
|
+
}
|
|
881
|
+
const sftp = await getSftp(client);
|
|
882
|
+
try {
|
|
883
|
+
const stats = await new Promise((resolve, reject) => {
|
|
884
|
+
sftp.stat(remotePath, (err, st) => {
|
|
885
|
+
if (err) return reject(err);
|
|
886
|
+
resolve(st);
|
|
887
|
+
});
|
|
888
|
+
});
|
|
889
|
+
const want = Math.max(0, Math.min(length, stats.size - offset));
|
|
890
|
+
if (want === 0) return { text: "", offset, bytesRead: 0, size: stats.size };
|
|
891
|
+
const handle = await new Promise((resolve, reject) => {
|
|
892
|
+
sftp.open(remotePath, "r", (err, h) => {
|
|
893
|
+
if (err) return reject(err);
|
|
894
|
+
resolve(h);
|
|
895
|
+
});
|
|
896
|
+
});
|
|
897
|
+
try {
|
|
898
|
+
const buf = Buffer.alloc(want);
|
|
899
|
+
let got = 0;
|
|
900
|
+
while (got < want) {
|
|
901
|
+
const n = await new Promise((resolve, reject) => {
|
|
902
|
+
sftp.read(handle, buf, got, want - got, offset + got, (err, bytesRead) => {
|
|
903
|
+
if (err) return reject(err);
|
|
904
|
+
resolve(bytesRead ?? 0);
|
|
905
|
+
});
|
|
906
|
+
});
|
|
907
|
+
if (n === 0) break;
|
|
908
|
+
got += n;
|
|
909
|
+
}
|
|
910
|
+
return { text: buf.subarray(0, got).toString("utf8"), offset, bytesRead: got, size: stats.size };
|
|
911
|
+
} finally {
|
|
912
|
+
sftp.close(handle, () => {
|
|
913
|
+
});
|
|
914
|
+
}
|
|
915
|
+
} finally {
|
|
916
|
+
sftp.end();
|
|
917
|
+
}
|
|
918
|
+
}
|
|
860
919
|
async function writeFile(client, remotePath, content) {
|
|
861
920
|
const sftp = await getSftp(client);
|
|
862
921
|
try {
|
|
@@ -1010,7 +1069,9 @@ function runArgsWithEnv(cmd, args, extraEnv) {
|
|
|
1010
1069
|
env,
|
|
1011
1070
|
encoding: "utf8",
|
|
1012
1071
|
timeout: 1e4,
|
|
1013
|
-
stdio: ["pipe", "pipe", "pipe"]
|
|
1072
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
1073
|
+
// No console window for ssh-add on Windows; see runArgs in diagnose.ts.
|
|
1074
|
+
windowsHide: true
|
|
1014
1075
|
});
|
|
1015
1076
|
return { stdout: stdout.trim(), ok: true };
|
|
1016
1077
|
} catch (e) {
|
|
@@ -1342,9 +1403,9 @@ function isPoolFullError(err) {
|
|
|
1342
1403
|
}
|
|
1343
1404
|
var MAX_TIMER_MS = 2147483647;
|
|
1344
1405
|
function defaultMaxPoolSize() {
|
|
1345
|
-
const raw = process.env.SSH_MCP_MAX_POOL_SIZE;
|
|
1346
|
-
if (!raw) return 100;
|
|
1347
|
-
const parsed = Number
|
|
1406
|
+
const raw = process.env.SSH_MCP_MAX_POOL_SIZE?.trim();
|
|
1407
|
+
if (!raw || !/^\d+$/.test(raw)) return 100;
|
|
1408
|
+
const parsed = Number(raw);
|
|
1348
1409
|
return Number.isFinite(parsed) && parsed > 0 ? parsed : 100;
|
|
1349
1410
|
}
|
|
1350
1411
|
var AUTH_FINGERPRINT_SECRET = randomBytes(32);
|
|
@@ -1532,6 +1593,7 @@ var ConnectionPool = class {
|
|
|
1532
1593
|
if (entry.client === client) {
|
|
1533
1594
|
entry.refCount = Math.max(0, entry.refCount - 1);
|
|
1534
1595
|
if (entry.refCount === 0 && !entry.dead) {
|
|
1596
|
+
if (entry.idleTimer) clearTimeout(entry.idleTimer);
|
|
1535
1597
|
entry.idleTimer = setTimeout(() => {
|
|
1536
1598
|
try {
|
|
1537
1599
|
entry.client.end();
|
|
@@ -1784,14 +1846,14 @@ async function find(client, options, timeoutMs = 3e4) {
|
|
|
1784
1846
|
`Invalid maxsize format: "${options.maxsize}". Expected: digits followed by optional c/w/b/k/M/G (e.g. "10M", "500k")`
|
|
1785
1847
|
);
|
|
1786
1848
|
}
|
|
1787
|
-
const
|
|
1788
|
-
const args = [shellQuote(
|
|
1849
|
+
const defuseFindOperand = (p) => p.startsWith("-") || FIND_EXPRESSION_TOKENS.has(p) ? `./${p}` : p;
|
|
1850
|
+
const args = [shellQuote(defuseFindOperand(options.path))];
|
|
1789
1851
|
if (options.maxdepth !== void 0) args.push("-maxdepth", shellQuote(String(options.maxdepth)));
|
|
1790
1852
|
if (options.type) args.push("-type", shellQuote(options.type));
|
|
1791
1853
|
if (options.name) args.push("-name", shellQuote(options.name));
|
|
1792
1854
|
if (options.minsize) args.push("-size", shellQuote(`+${options.minsize}`));
|
|
1793
1855
|
if (options.maxsize) args.push("-size", shellQuote(`-${options.maxsize}`));
|
|
1794
|
-
if (options.newer) args.push("-newer", shellQuote(options.newer));
|
|
1856
|
+
if (options.newer) args.push("-newer", shellQuote(defuseFindOperand(options.newer)));
|
|
1795
1857
|
const command = `find ${args.join(" ")}`;
|
|
1796
1858
|
const result = await exec(client, command, timeoutMs);
|
|
1797
1859
|
if (!result.stdout.trim() && result.stderr.trim()) {
|
|
@@ -1814,7 +1876,7 @@ async function tail(client, path, lines = 100, grep, timeoutMs = 3e4) {
|
|
|
1814
1876
|
return result.stdout;
|
|
1815
1877
|
}
|
|
1816
1878
|
async function serviceStatus(client, serviceName, timeoutMs = 3e4) {
|
|
1817
|
-
const result = await exec(client, `systemctl status -- ${shellQuote(serviceName)} 2>&1`, timeoutMs);
|
|
1879
|
+
const result = await exec(client, `LC_ALL=C systemctl status -- ${shellQuote(serviceName)} 2>&1`, timeoutMs);
|
|
1818
1880
|
const raw = result.stdout;
|
|
1819
1881
|
const activeMatch = raw.match(/Active:\s+(\S+)\s+\(([^)]+)\)/);
|
|
1820
1882
|
const descMatch = raw.match(/^[^\w\n]*\S+\s+-\s+(.+)$/m);
|
|
@@ -1901,6 +1963,9 @@ var connectionParams = {
|
|
|
1901
1963
|
var DEFAULT_TIMEOUT_MS = 3e4;
|
|
1902
1964
|
var poolWait = (timeoutMs = DEFAULT_TIMEOUT_MS) => ({ waitForCapacityMs: timeoutMs });
|
|
1903
1965
|
var SFTP_POOL_WAIT_NOTE = ` If the connection pool is full (SSH_MCP_MAX_POOL_SIZE, default 100), waits up to ${DEFAULT_TIMEOUT_MS / 1e3}s for a free slot before starting.`;
|
|
1966
|
+
var READ_ONLY_LOCAL = { readOnlyHint: true, openWorldHint: false };
|
|
1967
|
+
var READ_ONLY_REMOTE = { readOnlyHint: true, openWorldHint: true };
|
|
1968
|
+
var DESTRUCTIVE_REMOTE = { readOnlyHint: false, destructiveHint: true, openWorldHint: true };
|
|
1904
1969
|
var POLICY_EXEMPT_NOTE = " NOT gated by SSH_MCP_COMMAND_WHITELIST / SSH_MCP_COMMAND_BLACKLIST: command policy applies only to ssh_exec and ssh_multi_exec, so a blacklist such as `^rm` does NOT block this tool.";
|
|
1905
1970
|
var ENV_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
1906
1971
|
function applyEnvPrefix(command, env) {
|
|
@@ -1921,7 +1986,7 @@ function registerTools(server, pool) {
|
|
|
1921
1986
|
const connectionPool = pool ?? new ConnectionPool();
|
|
1922
1987
|
server.tool(
|
|
1923
1988
|
"ssh_exec",
|
|
1924
|
-
"Execute a command on a remote host via SSH. The command is interpreted by the remote login shell \u2014 pipes, redirects, globs, and other shell metacharacters work as expected. Returns
|
|
1989
|
+
"Execute a command on a remote host via SSH. The command is interpreted by the remote login shell \u2014 pipes, redirects, globs, and other shell metacharacters work as expected. Returns up to three content blocks in this order: the status (`[exit code: N]`, plus the signal and any truncation), then `[stderr]`, then `[stdout]`. Status comes first so a proxy that cuts long results (Yaw MCP keeps about 100 KB) never drops the exit code; for big output, filter or page on the remote (head, tail, grep). Use `env` to set environment variables for this call without modifying the command string. Subject to SSH_MCP_COMMAND_WHITELIST / SSH_MCP_COMMAND_BLACKLIST if configured (policy is checked against the env-prefixed command).",
|
|
1925
1990
|
{
|
|
1926
1991
|
...connectionParams,
|
|
1927
1992
|
command: z.string().describe("Shell command to execute on the remote host (interpreted by the remote login shell)"),
|
|
@@ -1936,13 +2001,16 @@ function registerTools(server, pool) {
|
|
|
1936
2001
|
conn,
|
|
1937
2002
|
async (client) => {
|
|
1938
2003
|
const result = await exec(client, finalCommand, timeoutMs);
|
|
1939
|
-
const
|
|
1940
|
-
if (result.
|
|
1941
|
-
if (result.
|
|
1942
|
-
|
|
1943
|
-
|
|
1944
|
-
|
|
1945
|
-
|
|
2004
|
+
const status = [`[exit code: ${result.code}]`];
|
|
2005
|
+
if (result.signal) status.push(`[signal: ${result.signal}]`);
|
|
2006
|
+
if (result.stdoutTruncated) status.push("[stdout truncated at the byte cap]");
|
|
2007
|
+
if (result.stderrTruncated) status.push("[stderr truncated at the byte cap]");
|
|
2008
|
+
const content = [{ type: "text", text: status.join("\n") }];
|
|
2009
|
+
if (result.stderr) content.push({ type: "text", text: `[stderr]
|
|
2010
|
+
${result.stderr}` });
|
|
2011
|
+
if (result.stdout) content.push({ type: "text", text: `[stdout]
|
|
2012
|
+
${result.stdout}` });
|
|
2013
|
+
return { content };
|
|
1946
2014
|
},
|
|
1947
2015
|
poolWait(timeoutMs)
|
|
1948
2016
|
);
|
|
@@ -1950,19 +2018,30 @@ ${result.stderr}`);
|
|
|
1950
2018
|
);
|
|
1951
2019
|
server.tool(
|
|
1952
2020
|
"ssh_read_file",
|
|
1953
|
-
`Read a file from a remote host via SFTP.${SFTP_POOL_WAIT_NOTE}`,
|
|
2021
|
+
`Read a file from a remote host via SFTP. A whole-file read is limited to 10 MB. For a large file -- or behind a proxy that caps results, such as Yaw MCP at about 100 KB -- read it in pages with \`offset\` and \`length\` (bytes): a paged read returns a status block first (\`[bytes A-B of SIZE]\` and the next offset) and then the text.${SFTP_POOL_WAIT_NOTE}`,
|
|
1954
2022
|
{
|
|
1955
2023
|
...connectionParams,
|
|
1956
|
-
path:
|
|
1957
|
-
|
|
1958
|
-
|
|
2024
|
+
path: AbsoluteRemotePathSchema.describe("Absolute path to the remote file. Must start with /."),
|
|
2025
|
+
offset: z.number().int().nonnegative().optional().describe("Byte offset to start reading at (default 0). Setting offset or length switches to a paged read."),
|
|
2026
|
+
length: z.number().int().positive().max(DEFAULT_MAX_READ_BYTES).optional().describe(
|
|
2027
|
+
"Number of bytes to read (default: the rest of the file, up to 10 MB). Behind Yaw MCP keep it at or under about 90000 so the page is not cut. A page boundary can split a multi-byte UTF-8 character."
|
|
2028
|
+
)
|
|
1959
2029
|
},
|
|
1960
|
-
|
|
2030
|
+
READ_ONLY_REMOTE,
|
|
2031
|
+
async ({ path, offset, length, ...conn }) => {
|
|
1961
2032
|
return connectionPool.withConnection(
|
|
1962
2033
|
conn,
|
|
1963
2034
|
async (client) => {
|
|
1964
|
-
|
|
1965
|
-
|
|
2035
|
+
if (offset === void 0 && length === void 0) {
|
|
2036
|
+
const content2 = await readFile(client, path);
|
|
2037
|
+
return { content: [{ type: "text", text: content2 }] };
|
|
2038
|
+
}
|
|
2039
|
+
const page = await readFileRange(client, path, offset ?? 0, length ?? DEFAULT_MAX_READ_BYTES);
|
|
2040
|
+
const end = page.offset + page.bytesRead;
|
|
2041
|
+
const where = page.bytesRead === 0 ? `[no bytes at offset ${page.offset}: the file is ${page.size} bytes]` : end < page.size ? `[bytes ${page.offset}-${end} of ${page.size}; next offset: ${end}]` : `[bytes ${page.offset}-${end} of ${page.size}; end of file]`;
|
|
2042
|
+
const content = [{ type: "text", text: where }];
|
|
2043
|
+
if (page.bytesRead > 0) content.push({ type: "text", text: page.text });
|
|
2044
|
+
return { content };
|
|
1966
2045
|
},
|
|
1967
2046
|
poolWait()
|
|
1968
2047
|
);
|
|
@@ -1976,6 +2055,7 @@ ${result.stderr}`);
|
|
|
1976
2055
|
path: AbsoluteRemotePathSchema.describe("Absolute path to the remote file. Must start with /."),
|
|
1977
2056
|
content: z.string().describe("File content to write")
|
|
1978
2057
|
},
|
|
2058
|
+
DESTRUCTIVE_REMOTE,
|
|
1979
2059
|
async ({ path, content, ...conn }) => {
|
|
1980
2060
|
return connectionPool.withConnection(
|
|
1981
2061
|
conn,
|
|
@@ -1990,7 +2070,7 @@ ${result.stderr}`);
|
|
|
1990
2070
|
);
|
|
1991
2071
|
server.tool(
|
|
1992
2072
|
"ssh_upload",
|
|
1993
|
-
`Upload a local file to a remote host via SFTP.${SFTP_POOL_WAIT_NOTE}${POLICY_EXEMPT_NOTE}`,
|
|
2073
|
+
`Upload a local file to a remote host via SFTP. Reads an arbitrary local file at an agent-chosen \`localPath\` -- any file readable by this process leaves the machine toward the remote host (the mirror direction of ssh_download's local write).${SFTP_POOL_WAIT_NOTE}${POLICY_EXEMPT_NOTE}`,
|
|
1994
2074
|
{
|
|
1995
2075
|
...connectionParams,
|
|
1996
2076
|
localPath: z.string().describe("Path to the local file to upload"),
|
|
@@ -2033,6 +2113,7 @@ ${result.stderr}`);
|
|
|
2033
2113
|
...connectionParams,
|
|
2034
2114
|
path: AbsoluteRemotePathSchema.describe("Absolute path to the remote directory. Must start with /.")
|
|
2035
2115
|
},
|
|
2116
|
+
READ_ONLY_REMOTE,
|
|
2036
2117
|
async ({ path, ...conn }) => {
|
|
2037
2118
|
return connectionPool.withConnection(
|
|
2038
2119
|
conn,
|
|
@@ -2054,6 +2135,7 @@ ${result.stderr}`);
|
|
|
2054
2135
|
...connectionParams,
|
|
2055
2136
|
path: AbsoluteRemotePathSchema.describe("Absolute path to the remote file or directory. Must start with /.")
|
|
2056
2137
|
},
|
|
2138
|
+
READ_ONLY_REMOTE,
|
|
2057
2139
|
async ({ path, ...conn }) => {
|
|
2058
2140
|
return connectionPool.withConnection(
|
|
2059
2141
|
conn,
|
|
@@ -2104,6 +2186,7 @@ ${result.stderr}`);
|
|
|
2104
2186
|
"Absolute path of the file or empty directory to delete. Must start with /."
|
|
2105
2187
|
)
|
|
2106
2188
|
},
|
|
2189
|
+
DESTRUCTIVE_REMOTE,
|
|
2107
2190
|
async ({ path, ...conn }) => {
|
|
2108
2191
|
return connectionPool.withConnection(
|
|
2109
2192
|
conn,
|
|
@@ -2122,6 +2205,7 @@ ${result.stderr}`);
|
|
|
2122
2205
|
host: HostSchema,
|
|
2123
2206
|
port: PortSchema
|
|
2124
2207
|
},
|
|
2208
|
+
READ_ONLY_REMOTE,
|
|
2125
2209
|
async ({ host, port }) => {
|
|
2126
2210
|
const report = diagnose(host, port || 22);
|
|
2127
2211
|
const lines = [];
|
|
@@ -2168,6 +2252,7 @@ ${result.stderr}`);
|
|
|
2168
2252
|
"ssh_key_list",
|
|
2169
2253
|
"List all SSH private keys in ~/.ssh/ with their type, fingerprint, and whether they are loaded in the agent. Use this to find which keys are available and which ones need to be loaded. Reports isError only when ~/.ssh exists but could not be read -- an absent or empty ~/.ssh is a successful answer with a ssh-keygen hint.",
|
|
2170
2254
|
{},
|
|
2255
|
+
READ_ONLY_LOCAL,
|
|
2171
2256
|
async () => {
|
|
2172
2257
|
const listing = listSshKeysDetailed();
|
|
2173
2258
|
if (listing.status === "unreadable") {
|
|
@@ -2232,6 +2317,7 @@ ${result.stderr}`);
|
|
|
2232
2317
|
{
|
|
2233
2318
|
host: HostSchema
|
|
2234
2319
|
},
|
|
2320
|
+
READ_ONLY_LOCAL,
|
|
2235
2321
|
async ({ host }) => {
|
|
2236
2322
|
const result = configLookup(host);
|
|
2237
2323
|
if ("error" in result) {
|
|
@@ -2304,7 +2390,9 @@ ${result.stderr}`);
|
|
|
2304
2390
|
"ssh_multi_exec",
|
|
2305
2391
|
"Execute a command on multiple remote hosts in parallel. Runs at most SSH_MCP_MAX_POOL_SIZE hosts at once (default 100) and works through a longer list as slots free up. `timeout` is per host: it bounds each host's command, not the whole call. Each parallel slot works through its share of the list one host at a time, so with the pool to itself a call can take up to about ceil(hosts / SSH_MCP_MAX_POOL_SIZE) x (`timeout` + connect time). The connection pool is shared with every other tool; when it is full this call waits for slots instead of failing, and gives up only when none of its own hosts holds a slot and a full `timeout` has passed with none of them starting or finishing. The hosts waiting at that point (up to one per parallel slot) and every host still queued then report `Connection pool is full`; the queued ones are never attempted. Rerun those hosts once the other calls finish, or raise SSH_MCP_MAX_POOL_SIZE. Returns results per host. Use this instead of calling ssh_exec multiple times \u2014 it's faster and shows results side by side. Use `env` to set environment variables for this call without modifying the command string. Subject to SSH_MCP_COMMAND_WHITELIST / SSH_MCP_COMMAND_BLACKLIST if configured (policy is checked once, against the env-prefixed command, before fan-out).",
|
|
2306
2392
|
{
|
|
2307
|
-
|
|
2393
|
+
// .min(1): `hosts: []` used to return an empty success block indistinguishable from a
|
|
2394
|
+
// no-op -- the same boundary rejection ssh_git_check applies to its empty host.
|
|
2395
|
+
hosts: z.array(z.string()).min(1, "hosts must not be empty. Name at least one host -- an empty list runs the command on nothing.").describe("List of SSH hostnames or IPs"),
|
|
2308
2396
|
command: z.string().describe("Shell command to execute on all hosts"),
|
|
2309
2397
|
port: PortSchema,
|
|
2310
2398
|
username: UsernameSchema,
|
|
@@ -2353,6 +2441,7 @@ ${result.stderr}`);
|
|
|
2353
2441
|
newer: z.string().optional().describe("Reference file path -- find matches files modified more recently than this file"),
|
|
2354
2442
|
timeout: TimeoutSchema
|
|
2355
2443
|
},
|
|
2444
|
+
READ_ONLY_REMOTE,
|
|
2356
2445
|
async ({ path, name, type, maxdepth, minsize, maxsize, newer, timeout, ...conn }) => {
|
|
2357
2446
|
const timeoutMs = timeout || DEFAULT_TIMEOUT_MS;
|
|
2358
2447
|
return connectionPool.withConnection(
|
|
@@ -2379,6 +2468,7 @@ ${files.join("\n")}` }] };
|
|
|
2379
2468
|
grep: z.string().optional().describe("Case-insensitive pattern to filter lines"),
|
|
2380
2469
|
timeout: TimeoutSchema
|
|
2381
2470
|
},
|
|
2471
|
+
READ_ONLY_REMOTE,
|
|
2382
2472
|
async ({ path, lines, grep, timeout, ...conn }) => {
|
|
2383
2473
|
const timeoutMs = timeout || DEFAULT_TIMEOUT_MS;
|
|
2384
2474
|
return connectionPool.withConnection(
|
|
@@ -2409,6 +2499,7 @@ ${files.join("\n")}` }] };
|
|
|
2409
2499
|
service: z.string().describe("Systemd service name (e.g. nginx, sshd, docker)"),
|
|
2410
2500
|
timeout: TimeoutSchema
|
|
2411
2501
|
},
|
|
2502
|
+
READ_ONLY_REMOTE,
|
|
2412
2503
|
async ({ service, timeout, ...conn }) => {
|
|
2413
2504
|
const timeoutMs = timeout || DEFAULT_TIMEOUT_MS;
|
|
2414
2505
|
return connectionPool.withConnection(
|
|
@@ -2433,11 +2524,25 @@ ${files.join("\n")}` }] };
|
|
|
2433
2524
|
|
|
2434
2525
|
// src/server.ts
|
|
2435
2526
|
var version = typeof __VERSION__ !== "undefined" ? __VERSION__ : JSON.parse(readFileSync4(join4(dirname(fileURLToPath(import.meta.url)), "..", "package.json"), "utf8")).version;
|
|
2527
|
+
var SERVER_INSTRUCTIONS = [
|
|
2528
|
+
"ssh-mcp runs commands and moves files on remote hosts over SSH, using ~/.ssh/config, the ssh-agent and known_hosts the way the OpenSSH client does.",
|
|
2529
|
+
"",
|
|
2530
|
+
"- After an auth, connect or host-key failure, run ssh_diagnose on that host before retrying. ssh_test is the quick check; ssh_key_list and ssh_key_load fix a key missing from the agent; ssh_known_hosts_fix replaces a stale host key.",
|
|
2531
|
+
"- Prefer the purpose-built tools over ssh_exec: ssh_read_file, ssh_ls, ssh_stat, ssh_find, ssh_tail, ssh_service_status. Use ssh_multi_exec for the same command on several hosts.",
|
|
2532
|
+
"- ssh_exec returns the exit code first, then stderr, then stdout. Proxies may cut long results (Yaw MCP keeps about 100 KB), so filter on the remote (grep, head, tail) instead of printing everything. Page a large file with ssh_read_file offset and length.",
|
|
2533
|
+
"- The SFTP tools take absolute remote paths (ssh_mkdir also accepts a relative one); ~ is not expanded.",
|
|
2534
|
+
"- Host keys: a host already in known_hosts must match. An unknown host is trusted on every connection unless strict mode (SSH_MCP_STRICT_HOSTKEYS=1) is on; add it with ssh_known_hosts_fix first.",
|
|
2535
|
+
"- On Windows the agent is the OpenSSH Authentication Agent service pipe; if agent auth fails, ssh_agent_ensure checks that the agent is reachable.",
|
|
2536
|
+
"- SSH_MCP_COMMAND_WHITELIST / SSH_MCP_COMMAND_BLACKLIST gate only ssh_exec and ssh_multi_exec, not the SFTP write and delete tools."
|
|
2537
|
+
].join("\n");
|
|
2436
2538
|
function createServer(pool) {
|
|
2437
|
-
const server = new McpServer(
|
|
2438
|
-
|
|
2439
|
-
|
|
2440
|
-
|
|
2539
|
+
const server = new McpServer(
|
|
2540
|
+
{
|
|
2541
|
+
name: "ssh-mcp",
|
|
2542
|
+
version
|
|
2543
|
+
},
|
|
2544
|
+
{ instructions: SERVER_INSTRUCTIONS }
|
|
2545
|
+
);
|
|
2441
2546
|
registerTools(server, pool);
|
|
2442
2547
|
return server;
|
|
2443
2548
|
}
|
package/dist/policy.d.ts
CHANGED
|
@@ -18,5 +18,5 @@ export interface PolicyContext {
|
|
|
18
18
|
* routed through here at all.
|
|
19
19
|
*/
|
|
20
20
|
export declare function enforcePolicy(command: string, context?: PolicyContext): void;
|
|
21
|
-
/** Returns true if any policy is currently configured.
|
|
21
|
+
/** Returns true if any policy is currently configured. Exported for library consumers and tests; tool descriptions use static "if configured" text instead. */
|
|
22
22
|
export declare function isPolicyConfigured(): boolean;
|
package/dist/server.d.ts
CHANGED
|
@@ -1,17 +1,24 @@
|
|
|
1
1
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
2
|
import type { ConnectionPool } from "./pool.js";
|
|
3
3
|
export declare const version: string;
|
|
4
|
+
/**
|
|
5
|
+
* Routing guidance sent once, at initialize. Yaw MCP shows it when this server is
|
|
6
|
+
* activated, capped at 2000 UTF-8 bytes, so it stays plain ASCII, well under that, and
|
|
7
|
+
* about these tools only (src/tests/server.test.ts pins both). It must not carry
|
|
8
|
+
* Yaw MCP's own framing markers, which the proxy strips or rejects.
|
|
9
|
+
*/
|
|
10
|
+
export declare const SERVER_INSTRUCTIONS: string;
|
|
4
11
|
export declare function createServer(pool?: ConnectionPool): McpServer;
|
|
5
12
|
export type { DiagnosticReport, DiagnosticResult } from "./diagnose.js";
|
|
6
13
|
export { checkConnectivity, checkKnownHosts, checkSshAgent, checkSshConfig, checkSshKeys, diagnose, } from "./diagnose.js";
|
|
7
|
-
export type { AgentResult, ConfigLookupResult, KeyInfo } from "./env.js";
|
|
8
|
-
export { checkGitSsh, configLookup, ensureAgent, fixKnownHosts, listSshKeys, loadKey, testConnection, } from "./env.js";
|
|
14
|
+
export type { AgentResult, ConfigLookupResult, KeyInfo, SshKeyListing } from "./env.js";
|
|
15
|
+
export { checkGitSsh, configLookup, ensureAgent, fixKnownHosts, listSshKeys, listSshKeysDetailed, loadKey, testConnection, } from "./env.js";
|
|
9
16
|
export type { FindOptions, MultiExecHost, MultiExecResult, ServiceStatus } from "./ops.js";
|
|
10
|
-
export { find, multiExec, serviceStatus, tail } from "./ops.js";
|
|
17
|
+
export { find, multiExec, serviceStatus, shellQuote, tail } from "./ops.js";
|
|
11
18
|
export type { PolicyContext } from "./policy.js";
|
|
12
19
|
export { enforcePolicy, isPolicyConfigured } from "./policy.js";
|
|
13
20
|
export type { AcquireOptions, PoolOptions } from "./pool.js";
|
|
14
21
|
export { ConnectionPool, isPoolFullError, POOL_FULL_ERROR_CODE, PoolFullError } from "./pool.js";
|
|
15
|
-
export type { ExecResult, FileStats, HostKeyRejection, HostKeyRejectionReason, ResolvedConfig, SSHConfig, } from "./ssh.js";
|
|
16
|
-
export { connect, connectRaw, connectWithProxy, deleteFile, downloadFile, exec, formatDiagnostics, listDir, makeDir, readFile, readKnownHostsKeys, resolveConfig, statFile, uploadFile, writeFile, } from "./ssh.js";
|
|
22
|
+
export type { ExecResult, FileRange, FileStats, HostKeyRejection, HostKeyRejectionReason, ResolvedConfig, SSHConfig, } from "./ssh.js";
|
|
23
|
+
export { connect, connectRaw, connectWithProxy, deleteFile, downloadFile, exec, formatDiagnostics, listDir, makeDir, readFile, readFileRange, readKnownHostsKeys, resolveConfig, statFile, uploadFile, writeFile, } from "./ssh.js";
|
|
17
24
|
export { registerTools } from "./tools.js";
|
package/dist/server.js
CHANGED
|
@@ -36,7 +36,12 @@ function isValidHostname(host) {
|
|
|
36
36
|
}
|
|
37
37
|
function runArgs(cmd, args) {
|
|
38
38
|
try {
|
|
39
|
-
const stdout = execFileSync(cmd, args, {
|
|
39
|
+
const stdout = execFileSync(cmd, args, {
|
|
40
|
+
encoding: "utf8",
|
|
41
|
+
timeout: 1e4,
|
|
42
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
43
|
+
windowsHide: true
|
|
44
|
+
});
|
|
40
45
|
return { stdout: stdout.trim(), ok: true };
|
|
41
46
|
} catch (e) {
|
|
42
47
|
const err = e;
|
|
@@ -449,8 +454,15 @@ function cachedKnownHostTypes(hosts, port) {
|
|
|
449
454
|
knownHostTypeCache.set(cacheKey, { at: now, types });
|
|
450
455
|
return types;
|
|
451
456
|
}
|
|
457
|
+
function isStrictHostKeyMode(env = process.env) {
|
|
458
|
+
const on = (v) => {
|
|
459
|
+
const t = (v ?? "").trim().toLowerCase();
|
|
460
|
+
return t === "1" || t === "true";
|
|
461
|
+
};
|
|
462
|
+
return on(env.SSH_MCP_STRICT_HOSTKEYS) || on(env.SSH_MCP_STRICT_HOST_KEY);
|
|
463
|
+
}
|
|
452
464
|
function buildHostVerifier(hosts, port, rejection) {
|
|
453
|
-
const strict =
|
|
465
|
+
const strict = isStrictHostKeyMode();
|
|
454
466
|
const label = hosts.join(" / ");
|
|
455
467
|
const remediationHost = unbracketHost(hosts[0]);
|
|
456
468
|
return (key) => {
|
|
@@ -460,7 +472,7 @@ function buildHostVerifier(hosts, port, rejection) {
|
|
|
460
472
|
if (strict) {
|
|
461
473
|
rejection.current = {
|
|
462
474
|
reason: "unknown-host-strict",
|
|
463
|
-
message: `no known_hosts entry for ${label}, and
|
|
475
|
+
message: `no known_hosts entry for ${label}, and strict mode (SSH_MCP_STRICT_HOSTKEYS=1) requires one. Add it: ssh-keyscan -H "${remediationHost}" >> ~/.ssh/known_hosts`
|
|
464
476
|
};
|
|
465
477
|
}
|
|
466
478
|
return !strict;
|
|
@@ -861,7 +873,7 @@ async function readFile(client, remotePath, maxBytes = DEFAULT_MAX_READ_BYTES) {
|
|
|
861
873
|
});
|
|
862
874
|
if (stats.size > maxBytes) {
|
|
863
875
|
throw new Error(
|
|
864
|
-
`File is ${(stats.size / 1024 / 1024).toFixed(1)} MB, exceeds ${(maxBytes / 1024 / 1024).toFixed(0)} MB limit.
|
|
876
|
+
`File is ${(stats.size / 1024 / 1024).toFixed(1)} MB, exceeds ${(maxBytes / 1024 / 1024).toFixed(0)} MB limit. Read it a page at a time with ssh_read_file's offset and length arguments (byte offsets; the file is ${stats.size} bytes).`
|
|
865
877
|
);
|
|
866
878
|
}
|
|
867
879
|
return await new Promise((resolve, reject) => {
|
|
@@ -874,6 +886,53 @@ async function readFile(client, remotePath, maxBytes = DEFAULT_MAX_READ_BYTES) {
|
|
|
874
886
|
sftp.end();
|
|
875
887
|
}
|
|
876
888
|
}
|
|
889
|
+
async function readFileRange(client, remotePath, offset, length, maxBytes = DEFAULT_MAX_READ_BYTES) {
|
|
890
|
+
if (!Number.isSafeInteger(offset) || offset < 0)
|
|
891
|
+
throw new Error(`offset must be a non-negative integer, got ${offset}`);
|
|
892
|
+
if (!Number.isSafeInteger(length) || length <= 0) throw new Error(`length must be a positive integer, got ${length}`);
|
|
893
|
+
if (length > maxBytes) {
|
|
894
|
+
throw new Error(
|
|
895
|
+
`length ${length} exceeds the ${(maxBytes / 1024 / 1024).toFixed(0)} MB limit for one read. Use a smaller length and page with offset.`
|
|
896
|
+
);
|
|
897
|
+
}
|
|
898
|
+
const sftp = await getSftp(client);
|
|
899
|
+
try {
|
|
900
|
+
const stats = await new Promise((resolve, reject) => {
|
|
901
|
+
sftp.stat(remotePath, (err, st) => {
|
|
902
|
+
if (err) return reject(err);
|
|
903
|
+
resolve(st);
|
|
904
|
+
});
|
|
905
|
+
});
|
|
906
|
+
const want = Math.max(0, Math.min(length, stats.size - offset));
|
|
907
|
+
if (want === 0) return { text: "", offset, bytesRead: 0, size: stats.size };
|
|
908
|
+
const handle = await new Promise((resolve, reject) => {
|
|
909
|
+
sftp.open(remotePath, "r", (err, h) => {
|
|
910
|
+
if (err) return reject(err);
|
|
911
|
+
resolve(h);
|
|
912
|
+
});
|
|
913
|
+
});
|
|
914
|
+
try {
|
|
915
|
+
const buf = Buffer.alloc(want);
|
|
916
|
+
let got = 0;
|
|
917
|
+
while (got < want) {
|
|
918
|
+
const n = await new Promise((resolve, reject) => {
|
|
919
|
+
sftp.read(handle, buf, got, want - got, offset + got, (err, bytesRead) => {
|
|
920
|
+
if (err) return reject(err);
|
|
921
|
+
resolve(bytesRead ?? 0);
|
|
922
|
+
});
|
|
923
|
+
});
|
|
924
|
+
if (n === 0) break;
|
|
925
|
+
got += n;
|
|
926
|
+
}
|
|
927
|
+
return { text: buf.subarray(0, got).toString("utf8"), offset, bytesRead: got, size: stats.size };
|
|
928
|
+
} finally {
|
|
929
|
+
sftp.close(handle, () => {
|
|
930
|
+
});
|
|
931
|
+
}
|
|
932
|
+
} finally {
|
|
933
|
+
sftp.end();
|
|
934
|
+
}
|
|
935
|
+
}
|
|
877
936
|
async function writeFile(client, remotePath, content) {
|
|
878
937
|
const sftp = await getSftp(client);
|
|
879
938
|
try {
|
|
@@ -1027,7 +1086,9 @@ function runArgsWithEnv(cmd, args, extraEnv) {
|
|
|
1027
1086
|
env,
|
|
1028
1087
|
encoding: "utf8",
|
|
1029
1088
|
timeout: 1e4,
|
|
1030
|
-
stdio: ["pipe", "pipe", "pipe"]
|
|
1089
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
1090
|
+
// No console window for ssh-add on Windows; see runArgs in diagnose.ts.
|
|
1091
|
+
windowsHide: true
|
|
1031
1092
|
});
|
|
1032
1093
|
return { stdout: stdout.trim(), ok: true };
|
|
1033
1094
|
} catch (e) {
|
|
@@ -1354,9 +1415,9 @@ function isPoolFullError(err) {
|
|
|
1354
1415
|
}
|
|
1355
1416
|
var MAX_TIMER_MS = 2147483647;
|
|
1356
1417
|
function defaultMaxPoolSize() {
|
|
1357
|
-
const raw = process.env.SSH_MCP_MAX_POOL_SIZE;
|
|
1358
|
-
if (!raw) return 100;
|
|
1359
|
-
const parsed = Number
|
|
1418
|
+
const raw = process.env.SSH_MCP_MAX_POOL_SIZE?.trim();
|
|
1419
|
+
if (!raw || !/^\d+$/.test(raw)) return 100;
|
|
1420
|
+
const parsed = Number(raw);
|
|
1360
1421
|
return Number.isFinite(parsed) && parsed > 0 ? parsed : 100;
|
|
1361
1422
|
}
|
|
1362
1423
|
var AUTH_FINGERPRINT_SECRET = randomBytes(32);
|
|
@@ -1544,6 +1605,7 @@ var ConnectionPool = class {
|
|
|
1544
1605
|
if (entry.client === client) {
|
|
1545
1606
|
entry.refCount = Math.max(0, entry.refCount - 1);
|
|
1546
1607
|
if (entry.refCount === 0 && !entry.dead) {
|
|
1608
|
+
if (entry.idleTimer) clearTimeout(entry.idleTimer);
|
|
1547
1609
|
entry.idleTimer = setTimeout(() => {
|
|
1548
1610
|
try {
|
|
1549
1611
|
entry.client.end();
|
|
@@ -1787,14 +1849,14 @@ async function find(client, options, timeoutMs = 3e4) {
|
|
|
1787
1849
|
`Invalid maxsize format: "${options.maxsize}". Expected: digits followed by optional c/w/b/k/M/G (e.g. "10M", "500k")`
|
|
1788
1850
|
);
|
|
1789
1851
|
}
|
|
1790
|
-
const
|
|
1791
|
-
const args = [shellQuote(
|
|
1852
|
+
const defuseFindOperand = (p) => p.startsWith("-") || FIND_EXPRESSION_TOKENS.has(p) ? `./${p}` : p;
|
|
1853
|
+
const args = [shellQuote(defuseFindOperand(options.path))];
|
|
1792
1854
|
if (options.maxdepth !== void 0) args.push("-maxdepth", shellQuote(String(options.maxdepth)));
|
|
1793
1855
|
if (options.type) args.push("-type", shellQuote(options.type));
|
|
1794
1856
|
if (options.name) args.push("-name", shellQuote(options.name));
|
|
1795
1857
|
if (options.minsize) args.push("-size", shellQuote(`+${options.minsize}`));
|
|
1796
1858
|
if (options.maxsize) args.push("-size", shellQuote(`-${options.maxsize}`));
|
|
1797
|
-
if (options.newer) args.push("-newer", shellQuote(options.newer));
|
|
1859
|
+
if (options.newer) args.push("-newer", shellQuote(defuseFindOperand(options.newer)));
|
|
1798
1860
|
const command = `find ${args.join(" ")}`;
|
|
1799
1861
|
const result = await exec(client, command, timeoutMs);
|
|
1800
1862
|
if (!result.stdout.trim() && result.stderr.trim()) {
|
|
@@ -1817,7 +1879,7 @@ async function tail(client, path, lines = 100, grep, timeoutMs = 3e4) {
|
|
|
1817
1879
|
return result.stdout;
|
|
1818
1880
|
}
|
|
1819
1881
|
async function serviceStatus(client, serviceName, timeoutMs = 3e4) {
|
|
1820
|
-
const result = await exec(client, `systemctl status -- ${shellQuote(serviceName)} 2>&1`, timeoutMs);
|
|
1882
|
+
const result = await exec(client, `LC_ALL=C systemctl status -- ${shellQuote(serviceName)} 2>&1`, timeoutMs);
|
|
1821
1883
|
const raw = result.stdout;
|
|
1822
1884
|
const activeMatch = raw.match(/Active:\s+(\S+)\s+\(([^)]+)\)/);
|
|
1823
1885
|
const descMatch = raw.match(/^[^\w\n]*\S+\s+-\s+(.+)$/m);
|
|
@@ -1907,6 +1969,9 @@ var connectionParams = {
|
|
|
1907
1969
|
var DEFAULT_TIMEOUT_MS = 3e4;
|
|
1908
1970
|
var poolWait = (timeoutMs = DEFAULT_TIMEOUT_MS) => ({ waitForCapacityMs: timeoutMs });
|
|
1909
1971
|
var SFTP_POOL_WAIT_NOTE = ` If the connection pool is full (SSH_MCP_MAX_POOL_SIZE, default 100), waits up to ${DEFAULT_TIMEOUT_MS / 1e3}s for a free slot before starting.`;
|
|
1972
|
+
var READ_ONLY_LOCAL = { readOnlyHint: true, openWorldHint: false };
|
|
1973
|
+
var READ_ONLY_REMOTE = { readOnlyHint: true, openWorldHint: true };
|
|
1974
|
+
var DESTRUCTIVE_REMOTE = { readOnlyHint: false, destructiveHint: true, openWorldHint: true };
|
|
1910
1975
|
var POLICY_EXEMPT_NOTE = " NOT gated by SSH_MCP_COMMAND_WHITELIST / SSH_MCP_COMMAND_BLACKLIST: command policy applies only to ssh_exec and ssh_multi_exec, so a blacklist such as `^rm` does NOT block this tool.";
|
|
1911
1976
|
var ENV_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
1912
1977
|
function applyEnvPrefix(command, env) {
|
|
@@ -1927,7 +1992,7 @@ function registerTools(server, pool) {
|
|
|
1927
1992
|
const connectionPool = pool ?? new ConnectionPool();
|
|
1928
1993
|
server.tool(
|
|
1929
1994
|
"ssh_exec",
|
|
1930
|
-
"Execute a command on a remote host via SSH. The command is interpreted by the remote login shell \u2014 pipes, redirects, globs, and other shell metacharacters work as expected. Returns
|
|
1995
|
+
"Execute a command on a remote host via SSH. The command is interpreted by the remote login shell \u2014 pipes, redirects, globs, and other shell metacharacters work as expected. Returns up to three content blocks in this order: the status (`[exit code: N]`, plus the signal and any truncation), then `[stderr]`, then `[stdout]`. Status comes first so a proxy that cuts long results (Yaw MCP keeps about 100 KB) never drops the exit code; for big output, filter or page on the remote (head, tail, grep). Use `env` to set environment variables for this call without modifying the command string. Subject to SSH_MCP_COMMAND_WHITELIST / SSH_MCP_COMMAND_BLACKLIST if configured (policy is checked against the env-prefixed command).",
|
|
1931
1996
|
{
|
|
1932
1997
|
...connectionParams,
|
|
1933
1998
|
command: z.string().describe("Shell command to execute on the remote host (interpreted by the remote login shell)"),
|
|
@@ -1942,13 +2007,16 @@ function registerTools(server, pool) {
|
|
|
1942
2007
|
conn,
|
|
1943
2008
|
async (client) => {
|
|
1944
2009
|
const result = await exec(client, finalCommand, timeoutMs);
|
|
1945
|
-
const
|
|
1946
|
-
if (result.
|
|
1947
|
-
if (result.
|
|
1948
|
-
|
|
1949
|
-
|
|
1950
|
-
|
|
1951
|
-
|
|
2010
|
+
const status = [`[exit code: ${result.code}]`];
|
|
2011
|
+
if (result.signal) status.push(`[signal: ${result.signal}]`);
|
|
2012
|
+
if (result.stdoutTruncated) status.push("[stdout truncated at the byte cap]");
|
|
2013
|
+
if (result.stderrTruncated) status.push("[stderr truncated at the byte cap]");
|
|
2014
|
+
const content = [{ type: "text", text: status.join("\n") }];
|
|
2015
|
+
if (result.stderr) content.push({ type: "text", text: `[stderr]
|
|
2016
|
+
${result.stderr}` });
|
|
2017
|
+
if (result.stdout) content.push({ type: "text", text: `[stdout]
|
|
2018
|
+
${result.stdout}` });
|
|
2019
|
+
return { content };
|
|
1952
2020
|
},
|
|
1953
2021
|
poolWait(timeoutMs)
|
|
1954
2022
|
);
|
|
@@ -1956,19 +2024,30 @@ ${result.stderr}`);
|
|
|
1956
2024
|
);
|
|
1957
2025
|
server.tool(
|
|
1958
2026
|
"ssh_read_file",
|
|
1959
|
-
`Read a file from a remote host via SFTP.${SFTP_POOL_WAIT_NOTE}`,
|
|
2027
|
+
`Read a file from a remote host via SFTP. A whole-file read is limited to 10 MB. For a large file -- or behind a proxy that caps results, such as Yaw MCP at about 100 KB -- read it in pages with \`offset\` and \`length\` (bytes): a paged read returns a status block first (\`[bytes A-B of SIZE]\` and the next offset) and then the text.${SFTP_POOL_WAIT_NOTE}`,
|
|
1960
2028
|
{
|
|
1961
2029
|
...connectionParams,
|
|
1962
|
-
path:
|
|
1963
|
-
|
|
1964
|
-
|
|
2030
|
+
path: AbsoluteRemotePathSchema.describe("Absolute path to the remote file. Must start with /."),
|
|
2031
|
+
offset: z.number().int().nonnegative().optional().describe("Byte offset to start reading at (default 0). Setting offset or length switches to a paged read."),
|
|
2032
|
+
length: z.number().int().positive().max(DEFAULT_MAX_READ_BYTES).optional().describe(
|
|
2033
|
+
"Number of bytes to read (default: the rest of the file, up to 10 MB). Behind Yaw MCP keep it at or under about 90000 so the page is not cut. A page boundary can split a multi-byte UTF-8 character."
|
|
2034
|
+
)
|
|
1965
2035
|
},
|
|
1966
|
-
|
|
2036
|
+
READ_ONLY_REMOTE,
|
|
2037
|
+
async ({ path, offset, length, ...conn }) => {
|
|
1967
2038
|
return connectionPool.withConnection(
|
|
1968
2039
|
conn,
|
|
1969
2040
|
async (client) => {
|
|
1970
|
-
|
|
1971
|
-
|
|
2041
|
+
if (offset === void 0 && length === void 0) {
|
|
2042
|
+
const content2 = await readFile(client, path);
|
|
2043
|
+
return { content: [{ type: "text", text: content2 }] };
|
|
2044
|
+
}
|
|
2045
|
+
const page = await readFileRange(client, path, offset ?? 0, length ?? DEFAULT_MAX_READ_BYTES);
|
|
2046
|
+
const end = page.offset + page.bytesRead;
|
|
2047
|
+
const where = page.bytesRead === 0 ? `[no bytes at offset ${page.offset}: the file is ${page.size} bytes]` : end < page.size ? `[bytes ${page.offset}-${end} of ${page.size}; next offset: ${end}]` : `[bytes ${page.offset}-${end} of ${page.size}; end of file]`;
|
|
2048
|
+
const content = [{ type: "text", text: where }];
|
|
2049
|
+
if (page.bytesRead > 0) content.push({ type: "text", text: page.text });
|
|
2050
|
+
return { content };
|
|
1972
2051
|
},
|
|
1973
2052
|
poolWait()
|
|
1974
2053
|
);
|
|
@@ -1982,6 +2061,7 @@ ${result.stderr}`);
|
|
|
1982
2061
|
path: AbsoluteRemotePathSchema.describe("Absolute path to the remote file. Must start with /."),
|
|
1983
2062
|
content: z.string().describe("File content to write")
|
|
1984
2063
|
},
|
|
2064
|
+
DESTRUCTIVE_REMOTE,
|
|
1985
2065
|
async ({ path, content, ...conn }) => {
|
|
1986
2066
|
return connectionPool.withConnection(
|
|
1987
2067
|
conn,
|
|
@@ -1996,7 +2076,7 @@ ${result.stderr}`);
|
|
|
1996
2076
|
);
|
|
1997
2077
|
server.tool(
|
|
1998
2078
|
"ssh_upload",
|
|
1999
|
-
`Upload a local file to a remote host via SFTP.${SFTP_POOL_WAIT_NOTE}${POLICY_EXEMPT_NOTE}`,
|
|
2079
|
+
`Upload a local file to a remote host via SFTP. Reads an arbitrary local file at an agent-chosen \`localPath\` -- any file readable by this process leaves the machine toward the remote host (the mirror direction of ssh_download's local write).${SFTP_POOL_WAIT_NOTE}${POLICY_EXEMPT_NOTE}`,
|
|
2000
2080
|
{
|
|
2001
2081
|
...connectionParams,
|
|
2002
2082
|
localPath: z.string().describe("Path to the local file to upload"),
|
|
@@ -2039,6 +2119,7 @@ ${result.stderr}`);
|
|
|
2039
2119
|
...connectionParams,
|
|
2040
2120
|
path: AbsoluteRemotePathSchema.describe("Absolute path to the remote directory. Must start with /.")
|
|
2041
2121
|
},
|
|
2122
|
+
READ_ONLY_REMOTE,
|
|
2042
2123
|
async ({ path, ...conn }) => {
|
|
2043
2124
|
return connectionPool.withConnection(
|
|
2044
2125
|
conn,
|
|
@@ -2060,6 +2141,7 @@ ${result.stderr}`);
|
|
|
2060
2141
|
...connectionParams,
|
|
2061
2142
|
path: AbsoluteRemotePathSchema.describe("Absolute path to the remote file or directory. Must start with /.")
|
|
2062
2143
|
},
|
|
2144
|
+
READ_ONLY_REMOTE,
|
|
2063
2145
|
async ({ path, ...conn }) => {
|
|
2064
2146
|
return connectionPool.withConnection(
|
|
2065
2147
|
conn,
|
|
@@ -2110,6 +2192,7 @@ ${result.stderr}`);
|
|
|
2110
2192
|
"Absolute path of the file or empty directory to delete. Must start with /."
|
|
2111
2193
|
)
|
|
2112
2194
|
},
|
|
2195
|
+
DESTRUCTIVE_REMOTE,
|
|
2113
2196
|
async ({ path, ...conn }) => {
|
|
2114
2197
|
return connectionPool.withConnection(
|
|
2115
2198
|
conn,
|
|
@@ -2128,6 +2211,7 @@ ${result.stderr}`);
|
|
|
2128
2211
|
host: HostSchema,
|
|
2129
2212
|
port: PortSchema
|
|
2130
2213
|
},
|
|
2214
|
+
READ_ONLY_REMOTE,
|
|
2131
2215
|
async ({ host, port }) => {
|
|
2132
2216
|
const report = diagnose(host, port || 22);
|
|
2133
2217
|
const lines = [];
|
|
@@ -2174,6 +2258,7 @@ ${result.stderr}`);
|
|
|
2174
2258
|
"ssh_key_list",
|
|
2175
2259
|
"List all SSH private keys in ~/.ssh/ with their type, fingerprint, and whether they are loaded in the agent. Use this to find which keys are available and which ones need to be loaded. Reports isError only when ~/.ssh exists but could not be read -- an absent or empty ~/.ssh is a successful answer with a ssh-keygen hint.",
|
|
2176
2260
|
{},
|
|
2261
|
+
READ_ONLY_LOCAL,
|
|
2177
2262
|
async () => {
|
|
2178
2263
|
const listing = listSshKeysDetailed();
|
|
2179
2264
|
if (listing.status === "unreadable") {
|
|
@@ -2238,6 +2323,7 @@ ${result.stderr}`);
|
|
|
2238
2323
|
{
|
|
2239
2324
|
host: HostSchema
|
|
2240
2325
|
},
|
|
2326
|
+
READ_ONLY_LOCAL,
|
|
2241
2327
|
async ({ host }) => {
|
|
2242
2328
|
const result = configLookup(host);
|
|
2243
2329
|
if ("error" in result) {
|
|
@@ -2310,7 +2396,9 @@ ${result.stderr}`);
|
|
|
2310
2396
|
"ssh_multi_exec",
|
|
2311
2397
|
"Execute a command on multiple remote hosts in parallel. Runs at most SSH_MCP_MAX_POOL_SIZE hosts at once (default 100) and works through a longer list as slots free up. `timeout` is per host: it bounds each host's command, not the whole call. Each parallel slot works through its share of the list one host at a time, so with the pool to itself a call can take up to about ceil(hosts / SSH_MCP_MAX_POOL_SIZE) x (`timeout` + connect time). The connection pool is shared with every other tool; when it is full this call waits for slots instead of failing, and gives up only when none of its own hosts holds a slot and a full `timeout` has passed with none of them starting or finishing. The hosts waiting at that point (up to one per parallel slot) and every host still queued then report `Connection pool is full`; the queued ones are never attempted. Rerun those hosts once the other calls finish, or raise SSH_MCP_MAX_POOL_SIZE. Returns results per host. Use this instead of calling ssh_exec multiple times \u2014 it's faster and shows results side by side. Use `env` to set environment variables for this call without modifying the command string. Subject to SSH_MCP_COMMAND_WHITELIST / SSH_MCP_COMMAND_BLACKLIST if configured (policy is checked once, against the env-prefixed command, before fan-out).",
|
|
2312
2398
|
{
|
|
2313
|
-
|
|
2399
|
+
// .min(1): `hosts: []` used to return an empty success block indistinguishable from a
|
|
2400
|
+
// no-op -- the same boundary rejection ssh_git_check applies to its empty host.
|
|
2401
|
+
hosts: z.array(z.string()).min(1, "hosts must not be empty. Name at least one host -- an empty list runs the command on nothing.").describe("List of SSH hostnames or IPs"),
|
|
2314
2402
|
command: z.string().describe("Shell command to execute on all hosts"),
|
|
2315
2403
|
port: PortSchema,
|
|
2316
2404
|
username: UsernameSchema,
|
|
@@ -2359,6 +2447,7 @@ ${result.stderr}`);
|
|
|
2359
2447
|
newer: z.string().optional().describe("Reference file path -- find matches files modified more recently than this file"),
|
|
2360
2448
|
timeout: TimeoutSchema
|
|
2361
2449
|
},
|
|
2450
|
+
READ_ONLY_REMOTE,
|
|
2362
2451
|
async ({ path, name, type, maxdepth, minsize, maxsize, newer, timeout, ...conn }) => {
|
|
2363
2452
|
const timeoutMs = timeout || DEFAULT_TIMEOUT_MS;
|
|
2364
2453
|
return connectionPool.withConnection(
|
|
@@ -2385,6 +2474,7 @@ ${files.join("\n")}` }] };
|
|
|
2385
2474
|
grep: z.string().optional().describe("Case-insensitive pattern to filter lines"),
|
|
2386
2475
|
timeout: TimeoutSchema
|
|
2387
2476
|
},
|
|
2477
|
+
READ_ONLY_REMOTE,
|
|
2388
2478
|
async ({ path, lines, grep, timeout, ...conn }) => {
|
|
2389
2479
|
const timeoutMs = timeout || DEFAULT_TIMEOUT_MS;
|
|
2390
2480
|
return connectionPool.withConnection(
|
|
@@ -2415,6 +2505,7 @@ ${files.join("\n")}` }] };
|
|
|
2415
2505
|
service: z.string().describe("Systemd service name (e.g. nginx, sshd, docker)"),
|
|
2416
2506
|
timeout: TimeoutSchema
|
|
2417
2507
|
},
|
|
2508
|
+
READ_ONLY_REMOTE,
|
|
2418
2509
|
async ({ service, timeout, ...conn }) => {
|
|
2419
2510
|
const timeoutMs = timeout || DEFAULT_TIMEOUT_MS;
|
|
2420
2511
|
return connectionPool.withConnection(
|
|
@@ -2439,11 +2530,25 @@ ${files.join("\n")}` }] };
|
|
|
2439
2530
|
|
|
2440
2531
|
// src/server.ts
|
|
2441
2532
|
var version = typeof __VERSION__ !== "undefined" ? __VERSION__ : JSON.parse(readFileSync4(join4(dirname(fileURLToPath(import.meta.url)), "..", "package.json"), "utf8")).version;
|
|
2533
|
+
var SERVER_INSTRUCTIONS = [
|
|
2534
|
+
"ssh-mcp runs commands and moves files on remote hosts over SSH, using ~/.ssh/config, the ssh-agent and known_hosts the way the OpenSSH client does.",
|
|
2535
|
+
"",
|
|
2536
|
+
"- After an auth, connect or host-key failure, run ssh_diagnose on that host before retrying. ssh_test is the quick check; ssh_key_list and ssh_key_load fix a key missing from the agent; ssh_known_hosts_fix replaces a stale host key.",
|
|
2537
|
+
"- Prefer the purpose-built tools over ssh_exec: ssh_read_file, ssh_ls, ssh_stat, ssh_find, ssh_tail, ssh_service_status. Use ssh_multi_exec for the same command on several hosts.",
|
|
2538
|
+
"- ssh_exec returns the exit code first, then stderr, then stdout. Proxies may cut long results (Yaw MCP keeps about 100 KB), so filter on the remote (grep, head, tail) instead of printing everything. Page a large file with ssh_read_file offset and length.",
|
|
2539
|
+
"- The SFTP tools take absolute remote paths (ssh_mkdir also accepts a relative one); ~ is not expanded.",
|
|
2540
|
+
"- Host keys: a host already in known_hosts must match. An unknown host is trusted on every connection unless strict mode (SSH_MCP_STRICT_HOSTKEYS=1) is on; add it with ssh_known_hosts_fix first.",
|
|
2541
|
+
"- On Windows the agent is the OpenSSH Authentication Agent service pipe; if agent auth fails, ssh_agent_ensure checks that the agent is reachable.",
|
|
2542
|
+
"- SSH_MCP_COMMAND_WHITELIST / SSH_MCP_COMMAND_BLACKLIST gate only ssh_exec and ssh_multi_exec, not the SFTP write and delete tools."
|
|
2543
|
+
].join("\n");
|
|
2442
2544
|
function createServer(pool) {
|
|
2443
|
-
const server = new McpServer(
|
|
2444
|
-
|
|
2445
|
-
|
|
2446
|
-
|
|
2545
|
+
const server = new McpServer(
|
|
2546
|
+
{
|
|
2547
|
+
name: "ssh-mcp",
|
|
2548
|
+
version
|
|
2549
|
+
},
|
|
2550
|
+
{ instructions: SERVER_INSTRUCTIONS }
|
|
2551
|
+
);
|
|
2447
2552
|
registerTools(server, pool);
|
|
2448
2553
|
return server;
|
|
2449
2554
|
}
|
|
@@ -2451,6 +2556,7 @@ export {
|
|
|
2451
2556
|
ConnectionPool,
|
|
2452
2557
|
POOL_FULL_ERROR_CODE,
|
|
2453
2558
|
PoolFullError,
|
|
2559
|
+
SERVER_INSTRUCTIONS,
|
|
2454
2560
|
checkConnectivity,
|
|
2455
2561
|
checkGitSsh,
|
|
2456
2562
|
checkKnownHosts,
|
|
@@ -2475,14 +2581,17 @@ export {
|
|
|
2475
2581
|
isPoolFullError,
|
|
2476
2582
|
listDir,
|
|
2477
2583
|
listSshKeys,
|
|
2584
|
+
listSshKeysDetailed,
|
|
2478
2585
|
loadKey,
|
|
2479
2586
|
makeDir,
|
|
2480
2587
|
multiExec,
|
|
2481
2588
|
readFile,
|
|
2589
|
+
readFileRange,
|
|
2482
2590
|
readKnownHostsKeys,
|
|
2483
2591
|
registerTools,
|
|
2484
2592
|
resolveConfig,
|
|
2485
2593
|
serviceStatus,
|
|
2594
|
+
shellQuote,
|
|
2486
2595
|
statFile,
|
|
2487
2596
|
tail,
|
|
2488
2597
|
testConnection,
|
package/dist/ssh.d.ts
CHANGED
|
@@ -20,7 +20,7 @@ export interface ExecResult {
|
|
|
20
20
|
}
|
|
21
21
|
/** Why the hostVerifier turned a server's host key down. */
|
|
22
22
|
export type HostKeyRejectionReason =
|
|
23
|
-
/** No known_hosts entry at all, and
|
|
23
|
+
/** No known_hosts entry at all, and strict mode is on (SSH_MCP_STRICT_HOSTKEYS, see isStrictHostKeyMode). */
|
|
24
24
|
"unknown-host-strict"
|
|
25
25
|
/** known_hosts has entries for this host, but none of the algorithm the server offered. */
|
|
26
26
|
| "algorithm-not-in-known-hosts"
|
|
@@ -107,6 +107,21 @@ export declare function hostKeyBlobType(key: Buffer): string | null;
|
|
|
107
107
|
export declare function hostKeyAlgorithmOrder(knownHostTypes: ReadonlyArray<string>): string[] | null;
|
|
108
108
|
/** Test-only: drop the memoized known_hosts key types. */
|
|
109
109
|
export declare function clearKnownHostTypeCache(): void;
|
|
110
|
+
/**
|
|
111
|
+
* Strict host-key mode: reject a host with no known_hosts entry instead of trusting it.
|
|
112
|
+
*
|
|
113
|
+
* Two names, one switch. SSH_MCP_STRICT_HOSTKEYS is the documented one.
|
|
114
|
+
* SSH_MCP_STRICT_HOST_KEY is the original name and keeps working, but it is
|
|
115
|
+
* credential-shaped -- `KEY` is a whole underscore segment -- so Yaw MCP's doctor
|
|
116
|
+
* listed `SSH_MCP_STRICT_HOST_KEY=1` as a plaintext secret and suggested moving it
|
|
117
|
+
* into the vault. `HOSTKEYS` is not a credential segment, so the new name is not
|
|
118
|
+
* flagged. Either one turns strict mode on.
|
|
119
|
+
*
|
|
120
|
+
* Both are read the way Yaw MCP reads an opt-in: trimmed, case-insensitive `1` or
|
|
121
|
+
* `true`. Only the exact string "1" used to count, so `=true` silently left strict
|
|
122
|
+
* mode OFF -- the unsafe direction for a security switch.
|
|
123
|
+
*/
|
|
124
|
+
export declare function isStrictHostKeyMode(env?: NodeJS.ProcessEnv): boolean;
|
|
110
125
|
export declare function resolveConfig(config: SSHConfig): ResolvedConfig;
|
|
111
126
|
export declare function formatDiagnostics(host: string): string;
|
|
112
127
|
export declare function connectRaw(connectConfig: ConnectConfig): Promise<Client>;
|
|
@@ -136,7 +151,7 @@ export interface JumpHop {
|
|
|
136
151
|
*
|
|
137
152
|
* It is also a host-key issue, not only a connectivity one: `knownHostsTargets`
|
|
138
153
|
* rejects those mangled spellings and returns [], and a verifier with zero known
|
|
139
|
-
* entries accepts ANY key unless
|
|
154
|
+
* entries accepts ANY key unless SSH_MCP_STRICT_HOSTKEYS=1. Parsing correctly is
|
|
140
155
|
* what puts the bastion hop back under real known_hosts checking.
|
|
141
156
|
*
|
|
142
157
|
* Grammar (OpenSSH ssh_config(5)): `[user@]host[:port]`, or the equivalent
|
|
@@ -173,7 +188,28 @@ export declare function enhanceSshError(err: unknown, host: string, resolved?: R
|
|
|
173
188
|
export declare function connect(config: SSHConfig): Promise<Client>;
|
|
174
189
|
export declare const DEFAULT_MAX_EXEC_BYTES: number;
|
|
175
190
|
export declare function exec(client: Client, command: string, timeoutMs?: number, maxBytes?: number): Promise<ExecResult>;
|
|
191
|
+
export declare const DEFAULT_MAX_READ_BYTES: number;
|
|
176
192
|
export declare function readFile(client: Client, remotePath: string, maxBytes?: number): Promise<string>;
|
|
193
|
+
/** One page of a remote file, plus where it sits in the whole. */
|
|
194
|
+
export interface FileRange {
|
|
195
|
+
/** The bytes read, decoded as UTF-8. A page boundary can split a multi-byte character. */
|
|
196
|
+
text: string;
|
|
197
|
+
/** Byte offset the page starts at (as requested). */
|
|
198
|
+
offset: number;
|
|
199
|
+
/** Number of bytes actually read: less than requested at end of file. */
|
|
200
|
+
bytesRead: number;
|
|
201
|
+
/** Size of the whole file, from the same stat. */
|
|
202
|
+
size: number;
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* Read `length` bytes of a remote file starting at byte `offset`.
|
|
206
|
+
*
|
|
207
|
+
* The paging half of ssh_read_file. A whole-file read is capped at 10 MB here, but a
|
|
208
|
+
* proxy in front of this server can cap far lower -- Yaw MCP keeps 100 KB of a result
|
|
209
|
+
* by default and cuts the rest -- so a large file has to be readable in pages without
|
|
210
|
+
* turning a cap off. `length` is bounded by the same `maxBytes` as a whole read.
|
|
211
|
+
*/
|
|
212
|
+
export declare function readFileRange(client: Client, remotePath: string, offset: number, length: number, maxBytes?: number): Promise<FileRange>;
|
|
177
213
|
export declare function writeFile(client: Client, remotePath: string, content: string): Promise<void>;
|
|
178
214
|
export declare function uploadFile(client: Client, localPath: string, remotePath: string): Promise<void>;
|
|
179
215
|
export declare function downloadFile(client: Client, remotePath: string, localPath: string): Promise<void>;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yawlabs/ssh-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.18.1",
|
|
4
4
|
"mcpName": "io.github.YawLabs/ssh-mcp",
|
|
5
5
|
"description": "SSH MCP server: run remote commands, transfer files over SFTP, manage ssh-agent keys and known_hosts, and auto-diagnose SSH failures.",
|
|
6
6
|
"type": "module",
|
|
@@ -28,6 +28,8 @@
|
|
|
28
28
|
"test": "vitest run",
|
|
29
29
|
"test:integration": "docker compose -f test/docker/docker-compose.yml up -d --build --wait && SSH_MCP_INTEGRATION=1 vitest run src/tests/integration.test.ts; docker compose -f test/docker/docker-compose.yml down",
|
|
30
30
|
"test:ci": "npm run build && npm test",
|
|
31
|
+
"check:compliance": "node scripts/check-compliance.mjs",
|
|
32
|
+
"check:oam-floor": "node scripts/check-oam-floor.mjs",
|
|
31
33
|
"prepublishOnly": "npm run build"
|
|
32
34
|
},
|
|
33
35
|
"keywords": [
|
|
@@ -72,8 +74,8 @@
|
|
|
72
74
|
"@types/node": "^26.1.1",
|
|
73
75
|
"@types/ssh2": "^1.15.5",
|
|
74
76
|
"@vitest/coverage-v8": "^4.1.11",
|
|
77
|
+
"@yawlabs/mcp-compliance": "^0.20.4",
|
|
75
78
|
"esbuild": "^0.28.1",
|
|
76
|
-
"postject": "^1.0.0-alpha.6",
|
|
77
79
|
"tsup": "^8.5.1",
|
|
78
80
|
"typescript": "^7.0.2",
|
|
79
81
|
"vitest": "^4.1.6"
|