@hheei/ssh-exec-mcp 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,206 @@
1
+ import { expect, test } from "bun:test";
2
+ import { chmod, mkdtemp, rm } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { createMcpServer, runCleanupSshProcess } from "./ssh-exec-mcp";
6
+ import { SessionManager } from "./session-manager";
7
+ import type { SshMountArgs } from "./ssh-mount";
8
+ import type { SshExecArgs, SshExecResult } from "./ssh-exec";
9
+
10
+ test("MCP initialize and tools/list expose ssh_exec and ssh_mount", async () => {
11
+ const server = createMcpServer({ execute: successfulExecute });
12
+
13
+ const initialize = await server.handle({
14
+ jsonrpc: "2.0",
15
+ id: 1,
16
+ method: "initialize",
17
+ params: {
18
+ protocolVersion: "2025-06-18",
19
+ capabilities: {},
20
+ clientInfo: { name: "test", version: "0" },
21
+ },
22
+ });
23
+ const tools = await server.handle({ jsonrpc: "2.0", id: 2, method: "tools/list" });
24
+
25
+ expect(initialize).toMatchObject({
26
+ jsonrpc: "2.0",
27
+ id: 1,
28
+ result: {
29
+ protocolVersion: "2025-06-18",
30
+ serverInfo: { name: "ssh-exec-mcp", version: "0.1.0" },
31
+ },
32
+ });
33
+ expect((tools as { result: { tools: Array<{ name: string }> } }).result.tools.map((tool) => tool.name)).toEqual([
34
+ "ssh_exec",
35
+ "ssh_mount",
36
+ ]);
37
+ });
38
+
39
+ test("tools/call rejects unsafe ssh_exec and ssh_mount hosts before execution", async () => {
40
+ let execCalls = 0;
41
+ let mountCalls = 0;
42
+ const server = createMcpServer({
43
+ execute: async (args: SshExecArgs) => {
44
+ execCalls += 1;
45
+ return await successfulExecute(args);
46
+ },
47
+ mount: async (args: SshMountArgs) => {
48
+ mountCalls += 1;
49
+ return { host: args.host, localPath: "/tmp/ssh-mount/prod", status: "mounted" };
50
+ },
51
+ });
52
+
53
+ const execResponse = await server.handle({
54
+ jsonrpc: "2.0",
55
+ id: 3,
56
+ method: "tools/call",
57
+ params: { name: "ssh_exec", arguments: { host: "-bad", command: "ok" } },
58
+ });
59
+ const mountResponse = await server.handle({
60
+ jsonrpc: "2.0",
61
+ id: 4,
62
+ method: "tools/call",
63
+ params: { name: "ssh_mount", arguments: { host: "-bad" } },
64
+ });
65
+
66
+ expect(execCalls).toBe(0);
67
+ expect(mountCalls).toBe(0);
68
+ expect(execResponse).toMatchObject({ jsonrpc: "2.0", id: 3, error: { code: -32602 } });
69
+ expect(mountResponse).toMatchObject({ jsonrpc: "2.0", id: 4, error: { code: -32602 } });
70
+ });
71
+
72
+ test("tools/call returns compact ssh_mount success text", async () => {
73
+ const server = createMcpServer({
74
+ mount: async (args: SshMountArgs) => ({
75
+ host: args.host,
76
+ localPath: "/tmp/ssh-mount/prod",
77
+ status: "remounted",
78
+ }),
79
+ });
80
+
81
+ const response = await server.handle({
82
+ jsonrpc: "2.0",
83
+ id: 5,
84
+ method: "tools/call",
85
+ params: { name: "ssh_mount", arguments: { host: "prod" } },
86
+ });
87
+ const result = (response as { result: { content: Array<{ text: string }>; structuredContent: Record<string, unknown> } }).result;
88
+
89
+ expect(result.content[0]?.text).toContain("Success.");
90
+ expect(result.content[0]?.text).toContain("Local path: /tmp/ssh-mount/prod/");
91
+ expect(result.content[0]?.text).toContain("Home path: /tmp/ssh-mount/prod/...");
92
+ expect(result.structuredContent).toEqual({
93
+ host: "prod",
94
+ localPath: "/tmp/ssh-mount/prod",
95
+ status: "remounted",
96
+ });
97
+ });
98
+
99
+ test("default MCP executor keeps timeout tail output for ssh_exec", async () => {
100
+ const tmpRoot = await mkdtemp(join(tmpdir(), "ssh-exec-mcp-timeout-test-"));
101
+ const fakeSsh = join(tmpRoot, "fake-timeout-ssh.ts");
102
+ await Bun.write(
103
+ fakeSsh,
104
+ `#!/usr/bin/env bun
105
+ import { mkdir } from "node:fs/promises";
106
+ import { dirname } from "node:path";
107
+
108
+ const args = process.argv.slice(2);
109
+ const socketPath = args[args.indexOf("-S") + 1];
110
+ if (args.includes("-O") && args.includes("check")) process.exit(255);
111
+ if (args.includes("-M") && args.includes("-N") && args.includes("-f")) {
112
+ await mkdir(dirname(socketPath), { recursive: true });
113
+ await Bun.write(socketPath, "master");
114
+ process.exit(0);
115
+ }
116
+ process.stdout.write("partial before timeout\\n");
117
+ await Bun.sleep(2000);
118
+ process.exit(0);
119
+ `,
120
+ );
121
+ await chmod(fakeSsh, 0o755);
122
+
123
+ try {
124
+ const server = createMcpServer({
125
+ manager: new SessionManager({ sshBin: fakeSsh, controlDir: join(tmpRoot, "control") }),
126
+ });
127
+ const response = await server.handle({
128
+ jsonrpc: "2.0",
129
+ id: 6,
130
+ method: "tools/call",
131
+ params: { name: "ssh_exec", arguments: { host: "prod", command: "slow", timeout: 2 } },
132
+ });
133
+ const text = (response as { result: { isError: boolean; content: Array<{ text: string }> } }).result.content[0]?.text ?? "";
134
+
135
+ expect((response as { result: { isError?: boolean } }).result.isError).toBe(true);
136
+ expect(text).toContain("partial before timeout");
137
+ expect(text).toContain("timed out");
138
+ } finally {
139
+ await rm(tmpRoot, { recursive: true, force: true });
140
+ }
141
+ });
142
+
143
+ test("cleanup runner captures stdout and stderr", async () => {
144
+ const tmpRoot = await mkdtemp(join(tmpdir(), "ssh-exec-cleanup-test-"));
145
+ const fakeSsh = join(tmpRoot, "fake-cleanup-ssh.ts");
146
+ await Bun.write(
147
+ fakeSsh,
148
+ `#!/usr/bin/env bun
149
+ process.stdout.write("cleanup out\\n");
150
+ process.stderr.write("cleanup err\\n");
151
+ process.exit(3);
152
+ `,
153
+ );
154
+ await chmod(fakeSsh, 0o755);
155
+
156
+ try {
157
+ const result = await runCleanupSshProcess(fakeSsh, ["-O", "exit"], 1000);
158
+ expect(result).toMatchObject({
159
+ exitCode: 3,
160
+ stdout: "cleanup out\n",
161
+ stderr: "cleanup err\n",
162
+ output: "cleanup out\ncleanup err\n",
163
+ truncated: false,
164
+ });
165
+ } finally {
166
+ await rm(tmpRoot, { recursive: true, force: true });
167
+ }
168
+ });
169
+
170
+ test("tools/call returns ssh_mount errors without fake mounted payload", async () => {
171
+ const server = createMcpServer({
172
+ mount: async () => {
173
+ throw new Error("mount failed");
174
+ },
175
+ });
176
+
177
+ const response = await server.handle({
178
+ jsonrpc: "2.0",
179
+ id: 7,
180
+ method: "tools/call",
181
+ params: { name: "ssh_mount", arguments: { host: "prod" } },
182
+ });
183
+ const result = (response as {
184
+ result: { isError?: boolean; content: Array<{ text: string }>; structuredContent?: unknown };
185
+ }).result;
186
+
187
+ expect(result.isError).toBe(true);
188
+ expect(result.content[0]?.text).toContain("mount failed");
189
+ expect(result).not.toHaveProperty("structuredContent");
190
+ });
191
+
192
+ async function successfulExecute(args: SshExecArgs): Promise<SshExecResult> {
193
+ return {
194
+ host: args.host,
195
+ exitCode: 0,
196
+ stdout: "hello\n",
197
+ stderr: "warn\n",
198
+ output: "hello\nwarn\n",
199
+ durationMs: 12,
200
+ truncated: false,
201
+ totalBytes: 11,
202
+ outputBytes: 11,
203
+ totalLines: 2,
204
+ outputLines: 2,
205
+ };
206
+ }
@@ -0,0 +1,368 @@
1
+ #!/usr/bin/env bun
2
+
3
+ import { homedir } from "node:os";
4
+
5
+ import { SessionManager } from "./session-manager";
6
+ import type { ProcessResult, SshMountResult } from "./session-manager";
7
+ import { executeSshMount, validateSshMountArgs, type SshMountArgs } from "./ssh-mount";
8
+ import {
9
+ executeSshExec,
10
+ readProcessOutputTail,
11
+ validateSshExecArgs,
12
+ type SshExecArgs,
13
+ type SshExecResult,
14
+ } from "./ssh-exec";
15
+
16
+ type JsonRpcId = string | number | null;
17
+
18
+ interface JsonRpcRequest {
19
+ jsonrpc: "2.0";
20
+ id?: JsonRpcId;
21
+ method?: string;
22
+ params?: unknown;
23
+ }
24
+
25
+ interface JsonRpcResponse {
26
+ jsonrpc: "2.0";
27
+ id: JsonRpcId;
28
+ result?: unknown;
29
+ error?: {
30
+ code: number;
31
+ message: string;
32
+ data?: unknown;
33
+ };
34
+ }
35
+
36
+ interface McpServerOptions {
37
+ execute?: (args: SshExecArgs) => Promise<SshExecResult>;
38
+ mount?: (args: SshMountArgs) => Promise<SshMountResult>;
39
+ manager?: SessionManager;
40
+ }
41
+
42
+ interface SshExecStructuredContent {
43
+ host: string;
44
+ exitCode: number | null;
45
+ output?: string;
46
+ durationMs: number;
47
+ truncated: boolean;
48
+ totalBytes?: number;
49
+ outputBytes?: number;
50
+ totalLines?: number;
51
+ outputLines?: number;
52
+ notice?: string;
53
+ }
54
+
55
+ interface SshMountStructuredContent {
56
+ host: string;
57
+ localPath: string;
58
+ status: SshMountResult["status"];
59
+ }
60
+
61
+ const PROTOCOL_VERSION = "2025-06-18";
62
+ const SERVER_INFO = { name: "ssh-exec-mcp", version: "0.1.0" };
63
+
64
+ const SSH_EXEC_TOOL = {
65
+ name: "ssh_exec",
66
+ description:
67
+ "Run a non-interactive command on a remote OpenSSH host. Use this after ssh_mount when you want to inspect state, verify a change, or restart or reload a service. Returns bounded output and exit metadata. Timeout defaults to 10 seconds.",
68
+ inputSchema: {
69
+ type: "object",
70
+ properties: {
71
+ host: { type: "string" },
72
+ command: { type: "string" },
73
+ timeout: { type: "number", default: 10 },
74
+ },
75
+ required: ["host", "command"],
76
+ },
77
+ };
78
+
79
+ const SSH_MOUNT_TOOL = {
80
+ name: "ssh_mount",
81
+ description:
82
+ "Mount a remote OpenSSH host locally through sshfs so built-in read, edit, and write tools can work on the returned local path. Reuses a healthy mount and remounts a stale one when needed. Supported on Linux and macOS.",
83
+ inputSchema: {
84
+ type: "object",
85
+ properties: {
86
+ host: { type: "string" },
87
+ },
88
+ required: ["host"],
89
+ },
90
+ };
91
+
92
+ export function createMcpServer(options: McpServerOptions = {}) {
93
+ const manager = options.manager ?? new SessionManager();
94
+ const execute = options.execute ?? (async (args: SshExecArgs) => await executeSshExec(manager, args, { timeoutMode: "result" }));
95
+ const mount = options.mount ?? (async (args: SshMountArgs) => {
96
+ const runner = async (runnerArgs: string[], timeoutMs?: number) =>
97
+ await runCleanupSshProcess(manager.sshBin, runnerArgs, timeoutMs, manager.sensitiveValues(args.host));
98
+ return await executeSshMount(manager, args, runner);
99
+ });
100
+
101
+ return {
102
+ manager,
103
+ async handle(message: JsonRpcRequest): Promise<JsonRpcResponse | undefined> {
104
+ const id = message.id ?? null;
105
+ try {
106
+ switch (message.method) {
107
+ case "initialize":
108
+ return resultResponse(id, {
109
+ protocolVersion: requestedProtocolVersion(message.params),
110
+ serverInfo: SERVER_INFO,
111
+ capabilities: { tools: {} },
112
+ });
113
+ case "notifications/initialized":
114
+ return undefined;
115
+ case "ping":
116
+ return resultResponse(id, {});
117
+ case "tools/list":
118
+ return resultResponse(id, { tools: [SSH_EXEC_TOOL, SSH_MOUNT_TOOL] });
119
+ case "tools/call":
120
+ return await handleToolCall(id, message.params, execute, mount);
121
+ default:
122
+ return errorResponse(id, -32601, `Method not found: ${message.method}`);
123
+ }
124
+ } catch (error) {
125
+ return errorResponse(id, -32603, error instanceof Error ? error.message : String(error));
126
+ }
127
+ },
128
+ };
129
+ }
130
+
131
+ async function handleToolCall(
132
+ id: JsonRpcId,
133
+ params: unknown,
134
+ execute: (args: SshExecArgs) => Promise<SshExecResult>,
135
+ mount: (args: SshMountArgs) => Promise<SshMountResult>,
136
+ ): Promise<JsonRpcResponse> {
137
+ if (!params || typeof params !== "object" || Array.isArray(params)) {
138
+ return errorResponse(id, -32602, "tools/call params must be an object");
139
+ }
140
+
141
+ const record = params as Record<string, unknown>;
142
+ if (record.name === "ssh_mount") {
143
+ let args: SshMountArgs;
144
+ try {
145
+ args = validateSshMountArgs(record.arguments);
146
+ } catch (error) {
147
+ return errorResponse(id, -32602, error instanceof Error ? error.message : String(error));
148
+ }
149
+
150
+ try {
151
+ const result = await mount(args);
152
+ return resultResponse(id, toolResultFromMount(result));
153
+ } catch (error) {
154
+ const message = error instanceof Error ? error.message : String(error);
155
+ return resultResponse(id, {
156
+ isError: true,
157
+ content: [{ type: "text", text: message }],
158
+ });
159
+ }
160
+ }
161
+
162
+ if (record.name !== "ssh_exec") {
163
+ return errorResponse(id, -32602, "Unknown tool");
164
+ }
165
+
166
+ let args: SshExecArgs;
167
+ try {
168
+ args = validateSshExecArgs(record.arguments);
169
+ } catch (error) {
170
+ return errorResponse(id, -32602, error instanceof Error ? error.message : String(error));
171
+ }
172
+
173
+ try {
174
+ const result = await execute(args);
175
+ return resultResponse(id, toolResultFromSsh(result, result.exitCode !== 0 || result.exitCode === null));
176
+ } catch (error) {
177
+ const message = error instanceof Error ? error.message : String(error);
178
+ return resultResponse(id, {
179
+ isError: true,
180
+ content: [{ type: "text", text: message }],
181
+ structuredContent: {
182
+ host: args.host,
183
+ exitCode: null,
184
+ durationMs: 0,
185
+ truncated: false,
186
+ notice: message,
187
+ } satisfies SshExecStructuredContent,
188
+ });
189
+ }
190
+ }
191
+
192
+ function toolResultFromSsh(result: SshExecResult, isError: boolean) {
193
+ const notice = result.notice ?? (isError && result.exitCode !== null && result.exitCode !== 0 ? `Command exited with code ${result.exitCode}` : undefined);
194
+ const outputText = result.output ?? `${result.stdout}${result.stderr}`;
195
+ const displayOutput = outputText ? outputText.trimEnd() : "(no output)";
196
+ const text = notice ? `${displayOutput}\n\n${notice}` : outputText || "(no output)";
197
+
198
+ const payload: Record<string, unknown> = {
199
+ content: [{ type: "text", text }],
200
+ structuredContent: structuredContentFromSsh(result, notice),
201
+ };
202
+ if (isError) payload.isError = true;
203
+ return payload;
204
+ }
205
+
206
+ function structuredContentFromSsh(result: SshExecResult, notice?: string): SshExecStructuredContent {
207
+ return {
208
+ host: result.host,
209
+ exitCode: result.exitCode,
210
+ output: result.output,
211
+ durationMs: result.durationMs,
212
+ truncated: result.truncated,
213
+ totalBytes: result.totalBytes,
214
+ outputBytes: result.outputBytes,
215
+ totalLines: result.totalLines,
216
+ outputLines: result.outputLines,
217
+ ...(notice ? { notice } : {}),
218
+ };
219
+ }
220
+
221
+ function toolResultFromMount(result: SshMountResult) {
222
+ const displayPath = ensureTrailingSlash(formatDisplayPath(result.localPath));
223
+ const text = [
224
+ "Success.",
225
+ `Local path: ${displayPath}`,
226
+ `Home path: ${displayPath}...`,
227
+ ].join("\n");
228
+
229
+ return {
230
+ content: [{ type: "text", text }],
231
+ structuredContent: {
232
+ host: result.host,
233
+ localPath: result.localPath,
234
+ status: result.status,
235
+ } satisfies SshMountStructuredContent,
236
+ };
237
+ }
238
+
239
+ function formatDisplayPath(path: string): string {
240
+ const home = homedir();
241
+ if (path === home) return "~";
242
+ if (path.startsWith(`${home}/`)) return `~${path.slice(home.length)}`;
243
+ return path;
244
+ }
245
+
246
+ function ensureTrailingSlash(path: string): string {
247
+ return path.endsWith("/") ? path : `${path}/`;
248
+ }
249
+
250
+ function requestedProtocolVersion(params: unknown): string {
251
+ if (!params || typeof params !== "object" || Array.isArray(params)) {
252
+ return PROTOCOL_VERSION;
253
+ }
254
+
255
+ const version = (params as Record<string, unknown>).protocolVersion;
256
+ return typeof version === "string" && version.trim() ? version : PROTOCOL_VERSION;
257
+ }
258
+
259
+ function resultResponse(id: JsonRpcId, result: unknown): JsonRpcResponse {
260
+ return { jsonrpc: "2.0", id, result };
261
+ }
262
+
263
+ function errorResponse(id: JsonRpcId, code: number, message: string, data?: unknown): JsonRpcResponse {
264
+ const error: JsonRpcResponse["error"] = { code, message };
265
+ if (data !== undefined) error.data = data;
266
+ return { jsonrpc: "2.0", id, error };
267
+ }
268
+
269
+ export async function runStdio(server: ReturnType<typeof createMcpServer>): Promise<void> {
270
+ const decoder = new TextDecoder();
271
+ let buffer = "";
272
+
273
+ for await (const chunk of Bun.stdin.stream()) {
274
+ buffer += decoder.decode(chunk, { stream: true });
275
+ const lines = buffer.split(/\r?\n/);
276
+ buffer = lines.pop() ?? "";
277
+ for (const line of lines) {
278
+ await handleLine(server, line);
279
+ }
280
+ }
281
+
282
+ if (buffer.trim()) {
283
+ await handleLine(server, buffer);
284
+ }
285
+ }
286
+
287
+ export async function runCleanupSshProcess(
288
+ sshBin: string,
289
+ args: string[],
290
+ timeoutMs = 5_000,
291
+ sensitiveValues: string[] = [],
292
+ ): Promise<ProcessResult> {
293
+ const child = Bun.spawn([sshBin, ...args], {
294
+ stdin: "ignore",
295
+ stdout: "pipe",
296
+ stderr: "pipe",
297
+ });
298
+ const timeout = setTimeout(() => child.kill("SIGTERM"), timeoutMs);
299
+ try {
300
+ const [output, exitCode] = await Promise.all([
301
+ readProcessOutputTail(child.stdout, child.stderr, sensitiveValues),
302
+ child.exited,
303
+ ]);
304
+ return {
305
+ exitCode,
306
+ output: output.text,
307
+ stdout: output.stdout,
308
+ stderr: output.stderr,
309
+ truncated: output.truncated,
310
+ totalBytes: output.totalBytes,
311
+ outputBytes: output.outputBytes,
312
+ totalLines: output.totalLines,
313
+ outputLines: output.outputLines,
314
+ };
315
+ } finally {
316
+ clearTimeout(timeout);
317
+ }
318
+ }
319
+
320
+ async function handleLine(server: ReturnType<typeof createMcpServer>, line: string): Promise<void> {
321
+ if (!line.trim()) return;
322
+
323
+ let response: JsonRpcResponse | undefined;
324
+ try {
325
+ response = await server.handle(JSON.parse(line));
326
+ } catch (error) {
327
+ response = errorResponse(null, -32700, "Parse error", error instanceof Error ? error.message : String(error));
328
+ }
329
+
330
+ if (response) {
331
+ process.stdout.write(`${JSON.stringify(response)}\n`);
332
+ }
333
+ }
334
+
335
+ if (import.meta.main) {
336
+ const server = createMcpServer();
337
+ const runner = (args: string[], timeoutMs?: number) =>
338
+ runCleanupSshProcess(server.manager.sshBin, args, timeoutMs, server.manager.sensitiveValues());
339
+
340
+ let cleanupPromise: Promise<void> | undefined;
341
+ const cleanup = async (budgetMs = 1_500) => {
342
+ cleanupPromise ??= Promise.race([
343
+ server.manager.closeAll(runner, Math.min(1_000, budgetMs)),
344
+ new Promise<void>((resolve) => setTimeout(resolve, budgetMs)),
345
+ ]).catch(() => undefined);
346
+ await cleanupPromise;
347
+ };
348
+
349
+ process.once("beforeExit", () => {
350
+ void cleanup();
351
+ });
352
+ process.on("SIGINT", () => {
353
+ void cleanup().finally(() => process.exit(130));
354
+ });
355
+ process.on("SIGTERM", () => {
356
+ void cleanup().finally(() => process.exit(143));
357
+ });
358
+
359
+ runStdio(server)
360
+ .then(async () => {
361
+ await cleanup();
362
+ process.exit(0);
363
+ })
364
+ .catch((error) => {
365
+ process.stderr.write(`${error instanceof Error ? error.stack ?? error.message : String(error)}\n`);
366
+ process.exit(1);
367
+ });
368
+ }