@yawlabs/ssh-mcp 0.14.1 → 0.15.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 +313 -291
- package/dist/diagnose.d.ts +33 -0
- package/dist/env.d.ts +87 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +990 -673
- package/dist/ops.d.ts +54 -0
- package/dist/policy.d.ts +22 -0
- package/dist/pool.d.ts +34 -0
- package/dist/server.d.ts +17 -223
- package/dist/server.js +989 -672
- package/dist/ssh-config.d.ts +4 -0
- package/dist/ssh.d.ts +217 -0
- package/dist/tools.d.ts +3 -0
- package/package.json +21 -9
package/dist/ssh.d.ts
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import { Client, type ConnectConfig } from "ssh2";
|
|
2
|
+
export interface SSHConfig {
|
|
3
|
+
host: string;
|
|
4
|
+
port?: number;
|
|
5
|
+
username?: string;
|
|
6
|
+
privateKeyPath?: string;
|
|
7
|
+
password?: string;
|
|
8
|
+
agent?: string;
|
|
9
|
+
}
|
|
10
|
+
export interface ExecResult {
|
|
11
|
+
stdout: string;
|
|
12
|
+
stderr: string;
|
|
13
|
+
code: number;
|
|
14
|
+
/** True when stdout was truncated at the byte cap. */
|
|
15
|
+
stdoutTruncated?: boolean;
|
|
16
|
+
/** True when stderr was truncated at the byte cap. */
|
|
17
|
+
stderrTruncated?: boolean;
|
|
18
|
+
/** Signal name (e.g. "TERM") if the remote channel closed via signal instead of exit. */
|
|
19
|
+
signal?: string;
|
|
20
|
+
}
|
|
21
|
+
/** Why the hostVerifier turned a server's host key down. */
|
|
22
|
+
export type HostKeyRejectionReason =
|
|
23
|
+
/** No known_hosts entry at all, and SSH_MCP_STRICT_HOST_KEY=1. */
|
|
24
|
+
"unknown-host-strict"
|
|
25
|
+
/** known_hosts has entries for this host, but none of the algorithm the server offered. */
|
|
26
|
+
| "algorithm-not-in-known-hosts"
|
|
27
|
+
/** known_hosts has an entry of the offered algorithm and the bytes differ. */
|
|
28
|
+
| "key-mismatch";
|
|
29
|
+
export interface HostKeyRejection {
|
|
30
|
+
reason: HostKeyRejectionReason;
|
|
31
|
+
message: string;
|
|
32
|
+
}
|
|
33
|
+
export interface ResolvedConfig {
|
|
34
|
+
connectConfig: ConnectConfig;
|
|
35
|
+
proxyJump?: string;
|
|
36
|
+
/**
|
|
37
|
+
* Side channel written by `connectConfig.hostVerifier` when it rejects a key.
|
|
38
|
+
* ssh2 reports every rejection with the same opaque "Host denied (verification
|
|
39
|
+
* failed)" string (lib/protocol/kex.js), so the verifier records the reason here
|
|
40
|
+
* and `enhanceSshError` folds it into the thrown error.
|
|
41
|
+
*/
|
|
42
|
+
hostKeyRejection?: {
|
|
43
|
+
current: HostKeyRejection | null;
|
|
44
|
+
};
|
|
45
|
+
/**
|
|
46
|
+
* Materializes `connectConfig.algorithms` (the host-key preference order).
|
|
47
|
+
* DELIBERATELY LAZY -- see the comment at its definition in `resolveConfig`.
|
|
48
|
+
* `connectWithProxy` calls it immediately before dialing; nothing else should,
|
|
49
|
+
* because nothing else can use the answer. Idempotent, and absent on a
|
|
50
|
+
* hand-built ResolvedConfig, so callers use `?.()`.
|
|
51
|
+
*/
|
|
52
|
+
applyHostKeyAlgorithms?: () => void;
|
|
53
|
+
}
|
|
54
|
+
/** Test-only: drop memoized `ssh -G` results so a suite can vary ssh_config. */
|
|
55
|
+
export declare function clearSshConfigCache(): void;
|
|
56
|
+
/**
|
|
57
|
+
* Strip the brackets off an IPv6 literal. Both spellings reach us: a caller may
|
|
58
|
+
* pass `[::1]` (the form `isValidHostname` accepts), while `ssh -G '[::1]'`
|
|
59
|
+
* answers `hostname ::1`, so `resolveConfig` also carries the bare form.
|
|
60
|
+
*/
|
|
61
|
+
export declare function unbracketHost(host: string): string;
|
|
62
|
+
/**
|
|
63
|
+
* The known_hosts spellings `ssh-keygen -F` will actually match, in lookup order.
|
|
64
|
+
*
|
|
65
|
+
* Verified against OpenSSH's ssh-keygen: for a default-port entry the address is
|
|
66
|
+
* stored (and looked up) BARE -- `-F '::1'` hits a `::1` line while `-F '[::1]'`
|
|
67
|
+
* misses it -- and for a non-default port it is `[::1]:2222`, never the
|
|
68
|
+
* double-bracketed `[[::1]]:2222` the old `[${host}]:${port}` template produced
|
|
69
|
+
* when handed an already-bracketed host.
|
|
70
|
+
*
|
|
71
|
+
* Returns [] when the host fails injection validation. IPv6 is validated in its
|
|
72
|
+
* bracketed form because that is the only shape `isValidHostname` recognizes
|
|
73
|
+
* (its plain-host regex has no ':'), which is why a bare `::1` -- the exact
|
|
74
|
+
* string `ssh -G` hands back -- used to fail validation and silently yield no
|
|
75
|
+
* known_hosts keys at all.
|
|
76
|
+
*/
|
|
77
|
+
export declare function knownHostsTargets(host: string, port?: number): string[];
|
|
78
|
+
export interface KnownHostEntry {
|
|
79
|
+
/** Host key type as recorded in known_hosts, e.g. "ssh-ed25519" or "ssh-rsa". */
|
|
80
|
+
type: string;
|
|
81
|
+
/** Raw public key blob (base64-decoded). */
|
|
82
|
+
key: Buffer;
|
|
83
|
+
}
|
|
84
|
+
export declare function readKnownHostsEntries(host: string, port?: number): KnownHostEntry[];
|
|
85
|
+
export declare function readKnownHostsKeys(host: string, port?: number): Buffer[];
|
|
86
|
+
/**
|
|
87
|
+
* Parse the algorithm name out of an SSH public-key blob. The wire format always
|
|
88
|
+
* begins with an SSH string: a uint32 big-endian length followed by that many
|
|
89
|
+
* bytes, e.g. "ssh-ed25519". Returns null if the blob is not shaped like one.
|
|
90
|
+
*/
|
|
91
|
+
export declare function hostKeyBlobType(key: Buffer): string | null;
|
|
92
|
+
/**
|
|
93
|
+
* Order the host-key algorithms so the ones we hold a known_hosts entry for are
|
|
94
|
+
* negotiated first -- what OpenSSH does, and the reason it does not reject a host
|
|
95
|
+
* whose known_hosts line is ecdsa while the server would rather offer ed25519.
|
|
96
|
+
*
|
|
97
|
+
* This is a PREFERENCE, never a restriction: the result is a permutation of
|
|
98
|
+
* ssh2's own default list, so exactly the same algorithm set stays negotiable and
|
|
99
|
+
* no reachable host becomes unconnectable. Deliberately built from ssh2's DEFAULT
|
|
100
|
+
* list rather than its SUPPORTED list -- the latter additionally contains ssh-dss,
|
|
101
|
+
* which ssh2 disables by default and we are not in the business of re-enabling.
|
|
102
|
+
*
|
|
103
|
+
* Returns null when there is nothing to do (no known_hosts types, ssh2's list
|
|
104
|
+
* unreadable, or the preferred set already covers all-or-none of the defaults),
|
|
105
|
+
* in which case the caller leaves `algorithms` unset.
|
|
106
|
+
*/
|
|
107
|
+
export declare function hostKeyAlgorithmOrder(knownHostTypes: ReadonlyArray<string>): string[] | null;
|
|
108
|
+
/** Test-only: drop the memoized known_hosts key types. */
|
|
109
|
+
export declare function clearKnownHostTypeCache(): void;
|
|
110
|
+
export declare function resolveConfig(config: SSHConfig): ResolvedConfig;
|
|
111
|
+
export declare function formatDiagnostics(host: string): string;
|
|
112
|
+
export declare function connectRaw(connectConfig: ConnectConfig): Promise<Client>;
|
|
113
|
+
/** One hop of a ProxyJump chain, split into the fields `resolveConfig` accepts. */
|
|
114
|
+
export interface JumpHop {
|
|
115
|
+
/** Hostname or ssh_config alias. IPv6 literals are UNBRACKETED, as `ssh -G` reports them. */
|
|
116
|
+
host: string;
|
|
117
|
+
/** Port from the spec; undefined leaves the choice to ssh_config / the default. */
|
|
118
|
+
port?: number;
|
|
119
|
+
/** Login from the spec; undefined leaves the choice to ssh_config / the environment. */
|
|
120
|
+
username?: string;
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Parse an OpenSSH ProxyJump value into its hops.
|
|
124
|
+
*
|
|
125
|
+
* `ssh -G <host>` prints the ProxyJump value VERBATIM -- it is the one field ssh
|
|
126
|
+
* does not resolve for us -- so everything OpenSSH accepts in `ProxyJump` / `-J`
|
|
127
|
+
* arrives here as one raw string and has to be split apart before any of it can be
|
|
128
|
+
* handed to `resolveConfig`. Feeding the whole string back in as a `host` (what
|
|
129
|
+
* this replaced) mangles every form but the bare hostname:
|
|
130
|
+
*
|
|
131
|
+
* - "jeff@bastion.example.com:2222" resolved to hostname "bastion.example.com:2222"
|
|
132
|
+
* on port 22 -- DNS fails before a byte is sent.
|
|
133
|
+
* - "[2001:db8::1]:2222" resolved to host 2001:db8::1 on port 22 -- WORSE than
|
|
134
|
+
* failing: it connects, silently, to the wrong port.
|
|
135
|
+
* - a comma list ("first:2201,second:2202") was treated as a single hostname.
|
|
136
|
+
*
|
|
137
|
+
* It is also a host-key issue, not only a connectivity one: `knownHostsTargets`
|
|
138
|
+
* rejects those mangled spellings and returns [], and a verifier with zero known
|
|
139
|
+
* entries accepts ANY key unless SSH_MCP_STRICT_HOST_KEY=1. Parsing correctly is
|
|
140
|
+
* what puts the bastion hop back under real known_hosts checking.
|
|
141
|
+
*
|
|
142
|
+
* Grammar (OpenSSH ssh_config(5)): `[user@]host[:port]`, or the equivalent
|
|
143
|
+
* `ssh://[user@]host[:port]` URI, with multiple hops separated by commas and
|
|
144
|
+
* visited left to right. Rules that matter:
|
|
145
|
+
* - the login is split at the LAST "@" (an IPv6 literal has no "@", and a
|
|
146
|
+
* password-style "user@domain@host" spelling keeps the trailing host).
|
|
147
|
+
* - brackets come off FIRST, so only a ":port" that follows "]" is a port. A
|
|
148
|
+
* BARE IPv6 literal is all colons and no port -- never split it.
|
|
149
|
+
* - a ":" suffix that is not a valid port number is left as part of the host, so
|
|
150
|
+
* a typo fails loudly instead of quietly dialing somewhere else.
|
|
151
|
+
*/
|
|
152
|
+
export declare function parseJumpSpec(spec: string): JumpHop[];
|
|
153
|
+
/**
|
|
154
|
+
* Render a hop back into ProxyJump spelling. Round-trips through `parseJumpSpec`
|
|
155
|
+
* (re-bracketing IPv6 so a port stays unambiguous), which is what lets a multi-hop
|
|
156
|
+
* chain be handed to the recursion below as a plain `proxyJump` string, and what
|
|
157
|
+
* makes the jump-host label in an error read the way the user wrote it.
|
|
158
|
+
*/
|
|
159
|
+
export declare function formatJumpHop(hop: JumpHop): string;
|
|
160
|
+
export declare function connectWithProxy(resolved: ResolvedConfig): Promise<Client>;
|
|
161
|
+
/**
|
|
162
|
+
* The single shape for turning an SSH failure into the diagnosed error this server
|
|
163
|
+
* advertises. Used by `connect()` and by `ConnectionPool.acquire()` for BOTH the
|
|
164
|
+
* config-resolution and the connect step, so there is one implementation rather
|
|
165
|
+
* than a copy per call site.
|
|
166
|
+
*
|
|
167
|
+
* Attaches, when available: the host-key rejection reason recorded by our
|
|
168
|
+
* hostVerifier (ssh2 itself only ever says "Host denied (verification failed)"),
|
|
169
|
+
* then the local SSH environment diagnostics. Returns the original error untouched
|
|
170
|
+
* when there is nothing to add, so `cause` chains stay short.
|
|
171
|
+
*/
|
|
172
|
+
export declare function enhanceSshError(err: unknown, host: string, resolved?: ResolvedConfig): unknown;
|
|
173
|
+
export declare function connect(config: SSHConfig): Promise<Client>;
|
|
174
|
+
export declare const DEFAULT_MAX_EXEC_BYTES: number;
|
|
175
|
+
export declare function exec(client: Client, command: string, timeoutMs?: number, maxBytes?: number): Promise<ExecResult>;
|
|
176
|
+
export declare function readFile(client: Client, remotePath: string, maxBytes?: number): Promise<string>;
|
|
177
|
+
export declare function writeFile(client: Client, remotePath: string, content: string): Promise<void>;
|
|
178
|
+
export declare function uploadFile(client: Client, localPath: string, remotePath: string): Promise<void>;
|
|
179
|
+
export declare function downloadFile(client: Client, remotePath: string, localPath: string): Promise<void>;
|
|
180
|
+
export declare function listDir(client: Client, remotePath: string): Promise<string[]>;
|
|
181
|
+
export interface FileStats {
|
|
182
|
+
size: number;
|
|
183
|
+
/** POSIX mode as a decimal number. Use modeOctal for the human-readable form. */
|
|
184
|
+
mode: number;
|
|
185
|
+
/** POSIX mode formatted as a 4-digit octal string (e.g. "0755"). */
|
|
186
|
+
modeOctal: string;
|
|
187
|
+
uid: number;
|
|
188
|
+
gid: number;
|
|
189
|
+
/** Unix timestamp (seconds since epoch) of last modification. */
|
|
190
|
+
mtime: number;
|
|
191
|
+
/** Unix timestamp (seconds since epoch) of last access. */
|
|
192
|
+
atime: number;
|
|
193
|
+
isFile: boolean;
|
|
194
|
+
isDirectory: boolean;
|
|
195
|
+
isSymbolicLink: boolean;
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* Two calls, deliberately, because one cannot answer both questions.
|
|
199
|
+
*
|
|
200
|
+
* TYPE comes from `lstat`, which reports on the path itself. SFTP `stat` FOLLOWS
|
|
201
|
+
* symlinks, so it can never report `isSymbolicLink: true` -- the flag this function
|
|
202
|
+
* has always returned, and which `ssh_stat` advertises, was dead: a symlink to a
|
|
203
|
+
* directory arrived as `isDirectory` and a dangling one rejected ENOENT before
|
|
204
|
+
* anything was formatted. `deleteFile` below already uses `lstat` for exactly this
|
|
205
|
+
* distinction.
|
|
206
|
+
*
|
|
207
|
+
* SIZE and the rest come from `stat`, the TARGET's metadata, because "how big is
|
|
208
|
+
* this" means the target -- a symlink's own size is the length of its path string,
|
|
209
|
+
* which is never the answer anyone wants. When the target cannot be resolved (a
|
|
210
|
+
* dangling link) we fall back to the link's own stats and still report it, rather
|
|
211
|
+
* than failing the call the way `stat` alone did.
|
|
212
|
+
*
|
|
213
|
+
* For a non-symlink the two calls agree by definition, so nothing changes there.
|
|
214
|
+
*/
|
|
215
|
+
export declare function statFile(client: Client, remotePath: string): Promise<FileStats>;
|
|
216
|
+
export declare function deleteFile(client: Client, remotePath: string): Promise<void>;
|
|
217
|
+
export declare function makeDir(client: Client, remotePath: string, recursive?: boolean): Promise<void>;
|
package/dist/tools.d.ts
ADDED
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yawlabs/ssh-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.15.1",
|
|
4
4
|
"mcpName": "io.github.YawLabs/ssh-mcp",
|
|
5
|
-
"description": "MCP server
|
|
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",
|
|
7
7
|
"bin": {
|
|
8
8
|
"ssh-mcp": "bin/ssh-mcp.mjs"
|
|
@@ -20,10 +20,10 @@
|
|
|
20
20
|
"README.md"
|
|
21
21
|
],
|
|
22
22
|
"scripts": {
|
|
23
|
-
"build": "tsup",
|
|
23
|
+
"build": "tsup && tsc -p tsconfig.build.json --emitDeclarationOnly",
|
|
24
24
|
"dev": "tsup --watch",
|
|
25
|
-
"lint": "
|
|
26
|
-
"lint:fix": "
|
|
25
|
+
"lint": "node scripts/lint.mjs check src/",
|
|
26
|
+
"lint:fix": "node scripts/lint.mjs check --write src/",
|
|
27
27
|
"typecheck": "tsc --noEmit",
|
|
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",
|
|
@@ -31,12 +31,22 @@
|
|
|
31
31
|
"prepublishOnly": "npm run build"
|
|
32
32
|
},
|
|
33
33
|
"keywords": [
|
|
34
|
-
"mcp",
|
|
35
34
|
"ssh",
|
|
36
|
-
"
|
|
35
|
+
"mcp",
|
|
37
36
|
"model-context-protocol",
|
|
37
|
+
"mcp-server",
|
|
38
|
+
"sftp",
|
|
39
|
+
"ssh-agent",
|
|
40
|
+
"ssh-keys",
|
|
41
|
+
"known-hosts",
|
|
42
|
+
"ssh-config",
|
|
43
|
+
"proxyjump",
|
|
44
|
+
"remote",
|
|
45
|
+
"remote-execution",
|
|
46
|
+
"diagnostics",
|
|
47
|
+
"devops",
|
|
38
48
|
"ai",
|
|
39
|
-
"
|
|
49
|
+
"ai-agents"
|
|
40
50
|
],
|
|
41
51
|
"author": "Yaw Labs <contact@yaw.sh>",
|
|
42
52
|
"license": "MIT",
|
|
@@ -56,6 +66,7 @@
|
|
|
56
66
|
"@biomejs/biome": "^2.4.15",
|
|
57
67
|
"@types/node": "^26.1.1",
|
|
58
68
|
"@types/ssh2": "^1.15.5",
|
|
69
|
+
"@vitest/coverage-v8": "^4.1.11",
|
|
59
70
|
"esbuild": "^0.28.1",
|
|
60
71
|
"postject": "^1.0.0-alpha.6",
|
|
61
72
|
"tsup": "^8.5.1",
|
|
@@ -64,5 +75,6 @@
|
|
|
64
75
|
},
|
|
65
76
|
"overrides": {
|
|
66
77
|
"esbuild": "^0.28.1"
|
|
67
|
-
}
|
|
78
|
+
},
|
|
79
|
+
"homepage": "https://yaw.sh/mcp-servers/ssh-mcp/"
|
|
68
80
|
}
|