enterprise-ssh-mcp-server 0.3.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.
package/dist/index.js ADDED
@@ -0,0 +1,1805 @@
1
+ #!/usr/bin/env node
2
+ import { randomUUID } from "node:crypto";
3
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
4
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
5
+ import { z } from "zod";
6
+ import { defaultEnvPath, ProfileRegistry } from "./env-profiles.js";
7
+ import { RuntimeConfigRegistry } from "./runtime-config.js";
8
+ import { SessionRegistry } from "./session-registry.js";
9
+ import { entryToJson, sftpChmod, sftpChown, sftpMkdir, sftpMkdirRecursive, sftpReadFileLimited, sftpReadDir, sftpReadlink, sftpRealpath, sftpRemove, sftpRename, sftpStat, sftpSymlink, sftpUnlink, sftpUtimes, sftpWriteFile, statsToJson, } from "./sftp-ops.js";
10
+ import { TransferManager } from "./transfer-manager.js";
11
+ import { assertNonEmpty, McpToolError, setCompactJsonResponse, toToolResult } from "./tool-result.js";
12
+ import { applyUnifiedPatch, assertExpectedSha256, createRemoteBackup, defaultTreeOptions, ensureProfileAllowsEdit, maxEditFileBytes, parseUnifiedPatch, patchTargetPath, publicSnapshot, readRemoteFileSnapshot, readRemoteTree, resolveProjectRoot, resolveSafeRemotePath, sha256, writeRemoteFileAtomic, } from "./remote-edit.js";
13
+ const SERVER_INSTRUCTIONS = [
14
+ "This server provides SSH/SFTP tools for configured remote hosts.",
15
+ "For quick remote inspection or health checks, prefer ssh_check_profile.",
16
+ "For one-shot commands on a configured profile, prefer ssh_run_profile instead of ssh_connect_profile + ssh_exec.",
17
+ "Use ssh_connect_profile plus session tools only when the user explicitly needs a persistent session, shell, SFTP workflow, transfer job, or tunnel.",
18
+ "Do not use local shell commands to simulate remote SSH checks when an SSH profile tool can do the work.",
19
+ "Dangerous tools such as tunnels, rm, chmod, chown, and disconnect_all are hidden unless explicitly enabled by runtime config.",
20
+ ].join(" ");
21
+ const server = new McpServer({
22
+ name: "enterprise-ssh-mcp-server",
23
+ version: "0.3.0",
24
+ }, {
25
+ instructions: SERVER_INSTRUCTIONS,
26
+ });
27
+ const envPath = defaultEnvPath();
28
+ const profiles = new ProfileRegistry(envPath);
29
+ const runtimeConfig = new RuntimeConfigRegistry(envPath);
30
+ setCompactJsonResponse(runtimeConfig.get().compactJson);
31
+ const sessions = new SessionRegistry();
32
+ const transfers = new TransferManager();
33
+ const editHistory = new Map();
34
+ const sessionIdSchema = z.string().min(1);
35
+ const wireEncodingSchema = z.enum(["utf8", "base64"]).default("utf8");
36
+ const positivePortSchema = z.number().int().min(0).max(65_535);
37
+ const modeSchema = z.union([z.number().int().min(0).max(0o7777), z.string().regex(/^[0-7]{3,4}$/)]);
38
+ const ptySchema = z.object({
39
+ rows: z.number().int().min(1).optional(),
40
+ cols: z.number().int().min(1).optional(),
41
+ height: z.number().int().min(0).optional(),
42
+ width: z.number().int().min(0).optional(),
43
+ term: z.string().min(1).optional(),
44
+ });
45
+ const projectRootSchema = z.string().min(1).optional();
46
+ const fileEncodingSchema = z.enum(["utf8", "base64"]).default("utf8");
47
+ const expectedShaSchema = z.union([z.string().length(64), z.literal("absent")]);
48
+ const expectedFileSchema = z.object({
49
+ path: z.string().min(1),
50
+ sha256: expectedShaSchema,
51
+ });
52
+ const projectPathSchema = z.string().min(1);
53
+ const AGENT_TOOL_NAMES = new Set([
54
+ "ssh_get_config",
55
+ "ssh_reload_config",
56
+ "ssh_list_profiles",
57
+ "ssh_reload_profiles",
58
+ "ssh_connect_profile",
59
+ "ssh_check_profile",
60
+ "ssh_run_profile",
61
+ "ssh_project_open",
62
+ "ssh_project_tree",
63
+ "ssh_project_search",
64
+ "ssh_project_diff",
65
+ "ssh_project_run_checks",
66
+ "ssh_project_validate_patch",
67
+ "ssh_project_apply_patch",
68
+ "ssh_project_stat",
69
+ "ssh_file_read",
70
+ "ssh_file_patch",
71
+ "ssh_edit_history",
72
+ "ssh_edit_rollback",
73
+ "ssh_list_sessions",
74
+ "ssh_disconnect",
75
+ "ssh_exec",
76
+ "ssh_shell_open",
77
+ "ssh_shell_write",
78
+ "ssh_shell_read",
79
+ "ssh_shell_close",
80
+ "sftp_list",
81
+ "sftp_stat",
82
+ "sftp_read_file",
83
+ "sftp_download_start",
84
+ "sftp_download_profile",
85
+ "sftp_transfer_status",
86
+ "sftp_transfer_list",
87
+ "sftp_transfer_cancel",
88
+ ]);
89
+ const DANGEROUS_TOOL_NAMES = new Set([
90
+ "ssh_disconnect_all",
91
+ "ssh_tunnel_local_start",
92
+ "ssh_tunnel_remote_start",
93
+ "ssh_tunnel_stop",
94
+ "sftp_rm",
95
+ "sftp_write_file",
96
+ "sftp_mkdir",
97
+ "sftp_rename",
98
+ "sftp_chmod",
99
+ "sftp_chown",
100
+ "sftp_touch",
101
+ "sftp_symlink",
102
+ "sftp_upload_start",
103
+ "sftp_upload_profile",
104
+ "ssh_file_write",
105
+ "ssh_file_delete",
106
+ "ssh_file_move",
107
+ ]);
108
+ const READ_ONLY_TOOL_NAMES = new Set([
109
+ "ssh_get_config",
110
+ "ssh_list_profiles",
111
+ "ssh_check_profile",
112
+ "ssh_project_open",
113
+ "ssh_project_tree",
114
+ "ssh_project_search",
115
+ "ssh_project_diff",
116
+ "ssh_project_stat",
117
+ "ssh_project_validate_patch",
118
+ "ssh_file_read",
119
+ "ssh_edit_history",
120
+ "ssh_list_sessions",
121
+ "ssh_shell_read",
122
+ "ssh_shell_list",
123
+ "sftp_list",
124
+ "sftp_stat",
125
+ "sftp_read_file",
126
+ "sftp_readlink",
127
+ "sftp_realpath",
128
+ "sftp_download_start",
129
+ "sftp_download_profile",
130
+ "sftp_transfer_status",
131
+ "sftp_transfer_list",
132
+ ]);
133
+ const LOCAL_ONLY_TOOL_NAMES = new Set([
134
+ "ssh_get_config",
135
+ "ssh_reload_config",
136
+ "ssh_list_profiles",
137
+ "ssh_reload_profiles",
138
+ "ssh_edit_history",
139
+ "ssh_list_sessions",
140
+ "sftp_transfer_status",
141
+ "sftp_transfer_list",
142
+ "sftp_transfer_cancel",
143
+ ]);
144
+ const REMOTE_CHECK_NAMES = ["identity", "pwd", "os", "uptime", "disk", "memory", "processes"];
145
+ const remoteCheckNameSchema = z.enum(REMOTE_CHECK_NAMES);
146
+ const DEFAULT_REMOTE_CHECKS = ["identity", "pwd", "os", "uptime", "disk", "memory"];
147
+ const REMOTE_CHECK_COMMANDS = {
148
+ identity: {
149
+ title: "User and host identity",
150
+ command: "printf 'whoami: '; whoami; printf 'hostname: '; hostname; id",
151
+ },
152
+ pwd: {
153
+ title: "Current directory",
154
+ command: "pwd",
155
+ },
156
+ os: {
157
+ title: "Operating system",
158
+ command: "uname -a || ver",
159
+ },
160
+ uptime: {
161
+ title: "Uptime",
162
+ command: "uptime || wmic os get lastbootuptime",
163
+ },
164
+ disk: {
165
+ title: "Disk usage",
166
+ command: "df -h . || wmic logicaldisk get caption,freespace,size",
167
+ },
168
+ memory: {
169
+ title: "Memory usage",
170
+ command: "free -h || vm_stat || wmic OS get FreePhysicalMemory,TotalVisibleMemorySize /Value",
171
+ },
172
+ processes: {
173
+ title: "Top processes",
174
+ command: "ps aux | head -n 15 || tasklist",
175
+ },
176
+ };
177
+ function registerTool(name, config, cb) {
178
+ const activeConfig = runtimeConfig.get();
179
+ if (activeConfig.toolset === "agent" && !AGENT_TOOL_NAMES.has(name)) {
180
+ return undefined;
181
+ }
182
+ if (!activeConfig.enableDangerousTools && DANGEROUS_TOOL_NAMES.has(name)) {
183
+ return undefined;
184
+ }
185
+ return server.registerTool(name, { ...config, annotations: toolAnnotations(name, config.annotations) }, cb);
186
+ }
187
+ function toolAnnotations(name, annotations) {
188
+ const readOnlyHint = READ_ONLY_TOOL_NAMES.has(name);
189
+ return {
190
+ readOnlyHint,
191
+ destructiveHint: readOnlyHint ? false : DANGEROUS_TOOL_NAMES.has(name),
192
+ openWorldHint: !LOCAL_ONLY_TOOL_NAMES.has(name),
193
+ ...annotations,
194
+ };
195
+ }
196
+ registerTool("ssh_get_config", {
197
+ title: "Get SSH MCP runtime config",
198
+ description: "Return non-secret runtime defaults loaded from the MCP .env file.",
199
+ inputSchema: {},
200
+ }, async () => toToolResult(async () => ({ ok: true, config: runtimeConfig.get() })));
201
+ registerTool("ssh_reload_config", {
202
+ title: "Reload SSH MCP runtime config",
203
+ description: "Reload non-secret runtime defaults from the MCP .env file.",
204
+ inputSchema: {},
205
+ }, async () => toToolResult(async () => {
206
+ const config = runtimeConfig.reload();
207
+ setCompactJsonResponse(config.compactJson);
208
+ return {
209
+ ok: true,
210
+ config,
211
+ note: "Tool visibility changes require restarting the MCP server because clients cache the tool list.",
212
+ };
213
+ }));
214
+ registerTool("ssh_list_profiles", {
215
+ title: "List SSH profiles",
216
+ description: "List SSH server profiles loaded from the MCP server .env file. Secret values are never returned.",
217
+ inputSchema: {},
218
+ }, async () => toToolResult(async () => ({
219
+ ok: true,
220
+ envPath: profiles.sourcePath(),
221
+ profiles: profiles.list(),
222
+ })));
223
+ registerTool("ssh_reload_profiles", {
224
+ title: "Reload SSH profiles",
225
+ description: "Reload SSH server profiles from the MCP server .env file without restarting the MCP server.",
226
+ inputSchema: {},
227
+ }, async () => toToolResult(async () => ({
228
+ ok: true,
229
+ envPath: profiles.sourcePath(),
230
+ profiles: profiles.reload(),
231
+ })));
232
+ registerTool("ssh_connect_profile", {
233
+ title: "Open SSH profile session",
234
+ description: "Open a persistent SSH session using an SSH_MCP_SERVER_<N>_* profile from .env.",
235
+ inputSchema: {
236
+ profileName: z.string().min(1),
237
+ sessionName: z.string().min(1).optional(),
238
+ tryKeyboard: z.boolean().optional(),
239
+ keyboardResponses: z.array(z.string()).default([]),
240
+ agentForward: z.boolean().optional(),
241
+ keepaliveIntervalMs: z.number().int().min(0).optional(),
242
+ keepaliveCountMax: z.number().int().min(1).optional(),
243
+ readyTimeoutMs: z.number().int().min(1_000).optional(),
244
+ connectionTimeoutMs: z.number().int().min(1_000).optional(),
245
+ hostHash: z.enum(["md5", "sha1", "sha256"]).optional(),
246
+ expectedHostHash: z.string().optional(),
247
+ strictVendor: z.boolean().default(true),
248
+ debug: z.boolean().default(false),
249
+ },
250
+ }, async (args) => toToolResult(async () => {
251
+ const connected = await connectProfileSession({
252
+ profileName: args.profileName,
253
+ sessionName: args.sessionName,
254
+ agentForward: args.agentForward,
255
+ tryKeyboard: args.tryKeyboard,
256
+ keyboardResponses: args.keyboardResponses,
257
+ keepaliveIntervalMs: args.keepaliveIntervalMs,
258
+ keepaliveCountMax: args.keepaliveCountMax,
259
+ readyTimeoutMs: args.readyTimeoutMs,
260
+ connectionTimeoutMs: args.connectionTimeoutMs,
261
+ strictVendor: args.strictVendor,
262
+ debug: args.debug,
263
+ hostHash: args.hostHash,
264
+ expectedHostHash: args.expectedHostHash,
265
+ });
266
+ return {
267
+ ok: true,
268
+ profile: connected.profile,
269
+ session: connected.session,
270
+ };
271
+ }));
272
+ registerTool("ssh_run_profile", {
273
+ title: "Run command on SSH profile",
274
+ description: "Use this first for one-shot remote commands on a configured SSH profile. Prefer this over connect + exec when the user only asks to run a command or inspect a server.",
275
+ inputSchema: {
276
+ profileName: z.string().min(1),
277
+ command: z.string().min(1),
278
+ workingDirectory: z.string().min(1).optional(),
279
+ keepSession: z.boolean().default(false),
280
+ outputEncoding: wireEncodingSchema,
281
+ timeoutMs: z.number().int().min(1).optional(),
282
+ maxOutputBytes: z.number().int().min(1).max(64 * 1024 * 1024).optional(),
283
+ },
284
+ }, async (args) => toToolResult(async () => {
285
+ const config = runtimeConfig.get();
286
+ const connected = await connectProfileSession({
287
+ profileName: args.profileName,
288
+ keyboardResponses: [],
289
+ strictVendor: true,
290
+ debug: false,
291
+ });
292
+ const command = buildRemoteCommand(args.command, args.workingDirectory);
293
+ try {
294
+ const result = await sessions.get(connected.session.id).exec({
295
+ command,
296
+ stdinEncoding: "utf8",
297
+ outputEncoding: args.outputEncoding,
298
+ timeoutMs: args.timeoutMs ?? config.defaultExecTimeoutMs,
299
+ maxOutputBytes: args.maxOutputBytes ?? config.defaultMaxOutputBytes,
300
+ });
301
+ return {
302
+ ok: true,
303
+ profile: connected.profile,
304
+ session: connected.session,
305
+ keptSession: args.keepSession,
306
+ result,
307
+ };
308
+ }
309
+ finally {
310
+ if (!args.keepSession) {
311
+ await sessions.disconnect(connected.session.id);
312
+ }
313
+ }
314
+ }));
315
+ registerTool("ssh_check_profile", {
316
+ title: "Check SSH profile host",
317
+ description: "Agent-friendly one-shot remote health check: connect to a named profile, run safe built-in inspection commands, return structured results, and disconnect.",
318
+ inputSchema: {
319
+ profileName: z.string().min(1),
320
+ checks: z.array(remoteCheckNameSchema).default(DEFAULT_REMOTE_CHECKS),
321
+ workingDirectory: z.string().min(1).optional(),
322
+ timeoutMs: z.number().int().min(1).optional(),
323
+ maxOutputBytes: z.number().int().min(1).max(64 * 1024 * 1024).optional(),
324
+ },
325
+ }, async (args) => toToolResult(async () => {
326
+ const config = runtimeConfig.get();
327
+ const connected = await connectProfileSession({
328
+ profileName: args.profileName,
329
+ keyboardResponses: [],
330
+ strictVendor: true,
331
+ debug: false,
332
+ });
333
+ const session = sessions.get(connected.session.id);
334
+ const selectedChecks = [...new Set(args.checks)];
335
+ try {
336
+ const checks = [];
337
+ for (const checkName of selectedChecks) {
338
+ const check = REMOTE_CHECK_COMMANDS[checkName];
339
+ checks.push(await runRemoteCheck({
340
+ name: checkName,
341
+ title: check.title,
342
+ command: buildRemoteCommand(check.command, args.workingDirectory),
343
+ timeoutMs: args.timeoutMs ?? config.defaultExecTimeoutMs,
344
+ maxOutputBytes: args.maxOutputBytes ?? config.defaultMaxOutputBytes,
345
+ execute: (command) => session.exec({
346
+ command,
347
+ stdinEncoding: "utf8",
348
+ outputEncoding: "utf8",
349
+ timeoutMs: args.timeoutMs ?? config.defaultExecTimeoutMs,
350
+ maxOutputBytes: args.maxOutputBytes ?? config.defaultMaxOutputBytes,
351
+ }),
352
+ }));
353
+ }
354
+ return {
355
+ ok: true,
356
+ profile: connected.profile,
357
+ session: connected.session,
358
+ checks,
359
+ };
360
+ }
361
+ finally {
362
+ await sessions.disconnect(connected.session.id);
363
+ }
364
+ }));
365
+ registerTool("ssh_project_open", {
366
+ title: "Open SSH project",
367
+ description: "Resolve and inspect the editable project root for a configured SSH profile.",
368
+ inputSchema: {
369
+ profileName: z.string().min(1),
370
+ projectRoot: projectRootSchema,
371
+ },
372
+ }, async (args) => toToolResult(async () => {
373
+ const workspace = await connectProjectWorkspace(args.profileName, args.projectRoot, false);
374
+ try {
375
+ return {
376
+ ok: true,
377
+ profile: projectProfileSummary(workspace.profile),
378
+ project: await inspectRemoteProject(workspace.session, workspace.rootPath),
379
+ };
380
+ }
381
+ finally {
382
+ await sessions.disconnect(workspace.sessionId);
383
+ }
384
+ }));
385
+ registerTool("ssh_project_tree", {
386
+ title: "Read SSH project tree",
387
+ description: "Return a bounded remote project tree rooted inside the profile edit root/default directory.",
388
+ inputSchema: {
389
+ profileName: z.string().min(1),
390
+ projectRoot: projectRootSchema,
391
+ maxDepth: z.number().int().min(1).max(12).optional(),
392
+ maxEntries: z.number().int().min(1).max(10_000).optional(),
393
+ includeHidden: z.boolean().optional(),
394
+ ignoreNames: z.array(z.string().min(1)).optional(),
395
+ },
396
+ }, async (args) => toToolResult(async () => {
397
+ const workspace = await connectProjectWorkspace(args.profileName, args.projectRoot, false);
398
+ try {
399
+ return {
400
+ ok: true,
401
+ tree: await readRemoteTree(workspace.sftp, workspace.rootPath, defaultTreeOptions(compactTreeOptions(args.maxDepth, args.maxEntries, args.includeHidden, args.ignoreNames))),
402
+ };
403
+ }
404
+ finally {
405
+ await sessions.disconnect(workspace.sessionId);
406
+ }
407
+ }));
408
+ registerTool("ssh_project_search", {
409
+ title: "Search SSH project",
410
+ description: "Search text in a remote project using rg when available, with grep fallback.",
411
+ inputSchema: {
412
+ profileName: z.string().min(1),
413
+ query: z.string().min(1),
414
+ projectRoot: projectRootSchema,
415
+ paths: z.array(projectPathSchema).default([]),
416
+ literal: z.boolean().default(true),
417
+ maxResults: z.number().int().min(1).max(1000).default(100),
418
+ contextLines: z.number().int().min(0).max(5).default(0),
419
+ timeoutMs: z.number().int().min(1).optional(),
420
+ maxOutputBytes: z.number().int().min(1).max(64 * 1024 * 1024).optional(),
421
+ },
422
+ }, async (args) => toToolResult(async () => {
423
+ const workspace = await connectProjectWorkspace(args.profileName, args.projectRoot, false);
424
+ try {
425
+ const config = runtimeConfig.get();
426
+ const paths = await resolveProjectPathArgs(workspace, args.paths, true);
427
+ const result = await workspace.session.exec({
428
+ command: buildProjectSearchCommand(workspace.rootPath, args.query, paths, args.literal, args.maxResults, args.contextLines),
429
+ stdinEncoding: "utf8",
430
+ outputEncoding: "utf8",
431
+ timeoutMs: args.timeoutMs ?? config.defaultExecTimeoutMs,
432
+ maxOutputBytes: args.maxOutputBytes ?? config.defaultMaxOutputBytes,
433
+ });
434
+ return {
435
+ ok: true,
436
+ projectRoot: workspace.rootPath,
437
+ matches: parseSearchMatches(result.stdout.text ?? ""),
438
+ truncated: result.stdout.truncated,
439
+ result,
440
+ };
441
+ }
442
+ finally {
443
+ await sessions.disconnect(workspace.sessionId);
444
+ }
445
+ }));
446
+ registerTool("ssh_project_stat", {
447
+ title: "Stat SSH project path",
448
+ description: "Return metadata for a file or directory inside the project root, with sha256 for editable files.",
449
+ inputSchema: {
450
+ profileName: z.string().min(1),
451
+ path: projectPathSchema,
452
+ projectRoot: projectRootSchema,
453
+ },
454
+ }, async (args) => toToolResult(async () => {
455
+ const workspace = await connectProjectWorkspace(args.profileName, args.projectRoot, false);
456
+ try {
457
+ const safePath = await resolveSafeRemotePath(workspace.sftp, workspace.profile, workspace.rootPath, args.path, true);
458
+ const stats = await sftpStat(workspace.sftp, safePath.remotePath, true);
459
+ const payload = {
460
+ ok: true,
461
+ path: args.path,
462
+ remotePath: safePath.remotePath,
463
+ stats: statsToJson(stats),
464
+ };
465
+ if (stats.isFile() && stats.size <= maxEditFileBytes(workspace.profile, runtimeConfig.get().defaultMaxEditFileBytes)) {
466
+ payload.sha256 = (await readRemoteFileSnapshot(workspace.sftp, safePath.remotePath, stats.size)).sha256;
467
+ }
468
+ return payload;
469
+ }
470
+ finally {
471
+ await sessions.disconnect(workspace.sessionId);
472
+ }
473
+ }));
474
+ registerTool("ssh_file_read", {
475
+ title: "Read SSH project file",
476
+ description: "Read a bounded file inside the project root and return content plus sha256 for safe edits.",
477
+ inputSchema: {
478
+ profileName: z.string().min(1),
479
+ path: projectPathSchema,
480
+ projectRoot: projectRootSchema,
481
+ encoding: fileEncodingSchema,
482
+ maxBytes: z.number().int().min(1).max(64 * 1024 * 1024).optional(),
483
+ },
484
+ }, async (args) => toToolResult(async () => {
485
+ const workspace = await connectProjectWorkspace(args.profileName, args.projectRoot, false);
486
+ try {
487
+ const safePath = await resolveSafeRemotePath(workspace.sftp, workspace.profile, workspace.rootPath, args.path, true);
488
+ const snapshot = await readRemoteFileSnapshot(workspace.sftp, safePath.remotePath, args.maxBytes ?? maxEditFileBytes(workspace.profile, runtimeConfig.get().defaultMaxEditFileBytes));
489
+ return { ok: true, file: publicSnapshot(snapshot, args.encoding) };
490
+ }
491
+ finally {
492
+ await sessions.disconnect(workspace.sessionId);
493
+ }
494
+ }));
495
+ registerTool("ssh_file_patch", {
496
+ title: "Patch SSH project file",
497
+ description: "Apply a unified diff to one file inside the project root using sha256 concurrency protection and atomic write.",
498
+ inputSchema: {
499
+ profileName: z.string().min(1),
500
+ path: projectPathSchema,
501
+ projectRoot: projectRootSchema,
502
+ unifiedPatch: z.string().min(1),
503
+ expectedSha256: expectedShaSchema,
504
+ createDirs: z.boolean().default(false),
505
+ },
506
+ annotations: { destructiveHint: true },
507
+ }, async (args) => toToolResult(async () => {
508
+ assertPatchSize(args.unifiedPatch);
509
+ const workspace = await connectProjectWorkspace(args.profileName, args.projectRoot, true);
510
+ try {
511
+ const patchFiles = parseUnifiedPatch(args.unifiedPatch);
512
+ if (patchFiles.length !== 1) {
513
+ throw new McpToolError("ssh_file_patch_file_count_invalid", "ssh_file_patch expects exactly one file in unifiedPatch", {
514
+ fileCount: patchFiles.length,
515
+ });
516
+ }
517
+ return {
518
+ ok: true,
519
+ edit: await applySinglePatchFile(workspace, args.path, patchFiles[0], args.expectedSha256, args.createDirs),
520
+ };
521
+ }
522
+ finally {
523
+ await sessions.disconnect(workspace.sessionId);
524
+ }
525
+ }));
526
+ registerTool("ssh_project_validate_patch", {
527
+ title: "Validate SSH project patch",
528
+ description: "Validate that a multi-file unified diff applies cleanly without modifying remote files.",
529
+ inputSchema: {
530
+ profileName: z.string().min(1),
531
+ projectRoot: projectRootSchema,
532
+ unifiedPatch: z.string().min(1),
533
+ expectedFiles: z.array(expectedFileSchema).default([]),
534
+ },
535
+ }, async (args) => toToolResult(async () => {
536
+ assertPatchSize(args.unifiedPatch);
537
+ const workspace = await connectProjectWorkspace(args.profileName, args.projectRoot, false);
538
+ try {
539
+ return {
540
+ ok: true,
541
+ validation: await validateProjectPatch(workspace, parseUnifiedPatch(args.unifiedPatch), args.expectedFiles),
542
+ };
543
+ }
544
+ finally {
545
+ await sessions.disconnect(workspace.sessionId);
546
+ }
547
+ }));
548
+ registerTool("ssh_project_apply_patch", {
549
+ title: "Apply SSH project patch",
550
+ description: "Apply a multi-file unified diff inside the project root with backups, sha256 protection, and rollback on failure.",
551
+ inputSchema: {
552
+ profileName: z.string().min(1),
553
+ projectRoot: projectRootSchema,
554
+ unifiedPatch: z.string().min(1),
555
+ expectedFiles: z.array(expectedFileSchema).min(1),
556
+ createDirs: z.boolean().default(false),
557
+ },
558
+ annotations: { destructiveHint: true },
559
+ }, async (args) => toToolResult(async () => {
560
+ assertPatchSize(args.unifiedPatch);
561
+ const workspace = await connectProjectWorkspace(args.profileName, args.projectRoot, true);
562
+ try {
563
+ return {
564
+ ok: true,
565
+ edit: await applyProjectPatch(workspace, parseUnifiedPatch(args.unifiedPatch), args.expectedFiles, args.createDirs),
566
+ };
567
+ }
568
+ finally {
569
+ await sessions.disconnect(workspace.sessionId);
570
+ }
571
+ }));
572
+ registerTool("ssh_file_write", {
573
+ title: "Write SSH project file",
574
+ description: "Overwrite or create one project file with sha256/absent protection and atomic write.",
575
+ inputSchema: {
576
+ profileName: z.string().min(1),
577
+ path: projectPathSchema,
578
+ projectRoot: projectRootSchema,
579
+ data: z.string(),
580
+ encoding: fileEncodingSchema,
581
+ expectedSha256: expectedShaSchema.optional(),
582
+ createDirs: z.boolean().default(false),
583
+ mode: z.number().int().min(0).max(0o7777).optional(),
584
+ },
585
+ annotations: { destructiveHint: true },
586
+ }, async (args) => toToolResult(async () => {
587
+ const workspace = await connectProjectWorkspace(args.profileName, args.projectRoot, true);
588
+ try {
589
+ return {
590
+ ok: true,
591
+ edit: await writeProjectFile(workspace, args.path, args.encoding === "base64" ? Buffer.from(args.data, "base64") : Buffer.from(args.data, "utf8"), args.expectedSha256, args.createDirs, args.mode),
592
+ };
593
+ }
594
+ finally {
595
+ await sessions.disconnect(workspace.sessionId);
596
+ }
597
+ }));
598
+ registerTool("ssh_file_delete", {
599
+ title: "Delete SSH project file",
600
+ description: "Delete one project file after sha256 verification. Requires profile ALLOW_DELETE=true.",
601
+ inputSchema: {
602
+ profileName: z.string().min(1),
603
+ path: projectPathSchema,
604
+ projectRoot: projectRootSchema,
605
+ expectedSha256: z.string().length(64),
606
+ },
607
+ annotations: { destructiveHint: true },
608
+ }, async (args) => toToolResult(async () => {
609
+ const workspace = await connectProjectWorkspace(args.profileName, args.projectRoot, true);
610
+ try {
611
+ return { ok: true, edit: await deleteProjectFile(workspace, args.path, args.expectedSha256) };
612
+ }
613
+ finally {
614
+ await sessions.disconnect(workspace.sessionId);
615
+ }
616
+ }));
617
+ registerTool("ssh_file_move", {
618
+ title: "Move SSH project file",
619
+ description: "Move or rename one project file inside the project root. Requires profile ALLOW_EDIT=true.",
620
+ inputSchema: {
621
+ profileName: z.string().min(1),
622
+ fromPath: projectPathSchema,
623
+ toPath: projectPathSchema,
624
+ projectRoot: projectRootSchema,
625
+ expectedSha256: z.string().length(64),
626
+ createDirs: z.boolean().default(false),
627
+ },
628
+ annotations: { destructiveHint: true },
629
+ }, async (args) => toToolResult(async () => {
630
+ const workspace = await connectProjectWorkspace(args.profileName, args.projectRoot, true);
631
+ try {
632
+ return {
633
+ ok: true,
634
+ edit: await moveProjectFile(workspace, args.fromPath, args.toPath, args.expectedSha256, args.createDirs),
635
+ };
636
+ }
637
+ finally {
638
+ await sessions.disconnect(workspace.sessionId);
639
+ }
640
+ }));
641
+ registerTool("ssh_project_diff", {
642
+ title: "Read SSH project diff",
643
+ description: "Return git status and diff for the remote project when it is a git repository.",
644
+ inputSchema: {
645
+ profileName: z.string().min(1),
646
+ projectRoot: projectRootSchema,
647
+ paths: z.array(projectPathSchema).default([]),
648
+ timeoutMs: z.number().int().min(1).optional(),
649
+ maxOutputBytes: z.number().int().min(1).max(64 * 1024 * 1024).optional(),
650
+ },
651
+ }, async (args) => toToolResult(async () => {
652
+ const workspace = await connectProjectWorkspace(args.profileName, args.projectRoot, false);
653
+ try {
654
+ const config = runtimeConfig.get();
655
+ const paths = await resolveProjectPathArgs(workspace, args.paths, false);
656
+ const result = await workspace.session.exec({
657
+ command: buildProjectDiffCommand(workspace.rootPath, paths),
658
+ stdinEncoding: "utf8",
659
+ outputEncoding: "utf8",
660
+ timeoutMs: args.timeoutMs ?? config.defaultExecTimeoutMs,
661
+ maxOutputBytes: args.maxOutputBytes ?? config.defaultMaxOutputBytes,
662
+ });
663
+ return { ok: true, projectRoot: workspace.rootPath, result };
664
+ }
665
+ finally {
666
+ await sessions.disconnect(workspace.sessionId);
667
+ }
668
+ }));
669
+ registerTool("ssh_project_run_checks", {
670
+ title: "Run SSH project checks",
671
+ description: "Run explicit project verification commands from the project root.",
672
+ inputSchema: {
673
+ profileName: z.string().min(1),
674
+ projectRoot: projectRootSchema,
675
+ checks: z.array(z.string().min(1)).min(1),
676
+ timeoutMs: z.number().int().min(1).optional(),
677
+ maxOutputBytes: z.number().int().min(1).max(64 * 1024 * 1024).optional(),
678
+ },
679
+ }, async (args) => toToolResult(async () => {
680
+ const workspace = await connectProjectWorkspace(args.profileName, args.projectRoot, false);
681
+ try {
682
+ const config = runtimeConfig.get();
683
+ const results = [];
684
+ for (const check of args.checks) {
685
+ results.push(await workspace.session.exec({
686
+ command: buildRemoteCommand(check, workspace.rootPath),
687
+ stdinEncoding: "utf8",
688
+ outputEncoding: "utf8",
689
+ timeoutMs: args.timeoutMs ?? config.defaultExecTimeoutMs,
690
+ maxOutputBytes: args.maxOutputBytes ?? config.defaultMaxOutputBytes,
691
+ }));
692
+ }
693
+ return {
694
+ ok: true,
695
+ projectRoot: workspace.rootPath,
696
+ passed: results.every((result) => result.exitCode === 0 && !result.timedOut),
697
+ results,
698
+ };
699
+ }
700
+ finally {
701
+ await sessions.disconnect(workspace.sessionId);
702
+ }
703
+ }));
704
+ registerTool("ssh_edit_history", {
705
+ title: "List SSH edit history",
706
+ description: "List in-memory edit records created by this MCP server process.",
707
+ inputSchema: {
708
+ profileName: z.string().min(1).optional(),
709
+ limit: z.number().int().min(1).max(500).default(50),
710
+ },
711
+ }, async (args) => toToolResult(async () => ({
712
+ ok: true,
713
+ edits: [...editHistory.values()]
714
+ .filter((edit) => args.profileName === undefined || edit.profileName === normalizeProfileNameForHistory(args.profileName))
715
+ .slice(-args.limit),
716
+ })));
717
+ registerTool("ssh_edit_rollback", {
718
+ title: "Rollback SSH edit",
719
+ description: "Rollback an edit created by this MCP server process using stored backup paths.",
720
+ inputSchema: {
721
+ profileName: z.string().min(1),
722
+ editId: z.string().min(1),
723
+ },
724
+ annotations: { destructiveHint: true },
725
+ }, async (args) => toToolResult(async () => {
726
+ const edit = editHistory.get(args.editId);
727
+ if (edit === undefined) {
728
+ throw new McpToolError("ssh_edit_not_found", "SSH edit id was not found", { editId: args.editId });
729
+ }
730
+ const workspace = await connectProjectWorkspace(args.profileName, edit.projectRoot, true);
731
+ try {
732
+ return { ok: true, rollback: await rollbackEdit(workspace, edit) };
733
+ }
734
+ finally {
735
+ await sessions.disconnect(workspace.sessionId);
736
+ }
737
+ }));
738
+ registerTool("ssh_connect", {
739
+ title: "Open SSH session",
740
+ description: "Open a persistent SSH connection. The returned sessionId is reused by exec, shell, SFTP, transfer, and tunnel tools.",
741
+ inputSchema: {
742
+ host: z.string().min(1),
743
+ port: z.number().int().min(1).max(65_535).optional(),
744
+ username: z.string().min(1),
745
+ name: z.string().min(1).optional(),
746
+ password: z.string().optional(),
747
+ privateKey: z.string().optional(),
748
+ privateKeyPath: z.string().optional(),
749
+ passphrase: z.string().optional(),
750
+ agent: z.string().optional(),
751
+ agentForward: z.boolean().optional(),
752
+ tryKeyboard: z.boolean().optional(),
753
+ keyboardResponses: z.array(z.string()).default([]),
754
+ keepaliveIntervalMs: z.number().int().min(0).optional(),
755
+ keepaliveCountMax: z.number().int().min(1).optional(),
756
+ readyTimeoutMs: z.number().int().min(1_000).optional(),
757
+ connectionTimeoutMs: z.number().int().min(1_000).optional(),
758
+ hostHash: z.enum(["md5", "sha1", "sha256"]).optional(),
759
+ expectedHostHash: z.string().optional(),
760
+ strictVendor: z.boolean().default(true),
761
+ debug: z.boolean().default(false),
762
+ },
763
+ }, async (args) => toToolResult(async () => ({
764
+ ok: true,
765
+ session: await sessions.connect({
766
+ host: args.host,
767
+ port: args.port ?? runtimeConfig.get().defaultSshPort,
768
+ username: args.username,
769
+ agentForward: args.agentForward ?? runtimeConfig.get().defaultAgentForward,
770
+ tryKeyboard: args.tryKeyboard ?? runtimeConfig.get().defaultTryKeyboard,
771
+ keyboardResponses: args.keyboardResponses,
772
+ keepaliveIntervalMs: args.keepaliveIntervalMs ?? runtimeConfig.get().defaultKeepaliveIntervalMs,
773
+ keepaliveCountMax: args.keepaliveCountMax ?? runtimeConfig.get().defaultKeepaliveCountMax,
774
+ readyTimeoutMs: args.readyTimeoutMs ?? runtimeConfig.get().defaultReadyTimeoutMs,
775
+ connectionTimeoutMs: args.connectionTimeoutMs ?? runtimeConfig.get().defaultConnectionTimeoutMs,
776
+ strictVendor: args.strictVendor,
777
+ debug: args.debug,
778
+ ...(args.name === undefined ? {} : { name: args.name }),
779
+ ...(args.password === undefined ? {} : { password: args.password }),
780
+ ...(args.privateKey === undefined ? {} : { privateKey: args.privateKey }),
781
+ ...(args.privateKeyPath === undefined ? {} : { privateKeyPath: args.privateKeyPath }),
782
+ ...(args.passphrase === undefined ? {} : { passphrase: args.passphrase }),
783
+ ...(args.agent === undefined ? {} : { agent: args.agent }),
784
+ ...(args.hostHash === undefined ? {} : { hostHash: args.hostHash }),
785
+ ...(args.expectedHostHash === undefined ? {} : { expectedHostHash: args.expectedHostHash }),
786
+ }),
787
+ })));
788
+ registerTool("ssh_list_sessions", {
789
+ title: "List SSH sessions",
790
+ description: "List persistent SSH sessions held by this MCP server process.",
791
+ inputSchema: {},
792
+ }, async () => toToolResult(async () => ({ ok: true, sessions: sessions.list() })));
793
+ registerTool("ssh_disconnect", {
794
+ title: "Disconnect SSH session",
795
+ description: "Close one SSH session and all shells, SFTP handles, and tunnels attached to it.",
796
+ inputSchema: { sessionId: sessionIdSchema },
797
+ }, async (args) => toToolResult(async () => ({ ok: true, session: await sessions.disconnect(args.sessionId) })));
798
+ registerTool("ssh_disconnect_all", {
799
+ title: "Disconnect all SSH sessions",
800
+ description: "Close every SSH session held by this MCP server process.",
801
+ inputSchema: {},
802
+ }, async () => toToolResult(async () => ({ ok: true, sessions: await sessions.disconnectAll() })));
803
+ registerTool("ssh_exec", {
804
+ title: "Execute remote command",
805
+ description: "Run a command on an existing persistent SSH session and capture bounded stdout/stderr. Requires sessionId; use ssh_run_profile for one-shot profile commands.",
806
+ inputSchema: {
807
+ sessionId: sessionIdSchema,
808
+ command: z.string().min(1),
809
+ env: z.record(z.string()).optional(),
810
+ stdin: z.string().optional(),
811
+ stdinEncoding: wireEncodingSchema,
812
+ outputEncoding: wireEncodingSchema,
813
+ timeoutMs: z.number().int().min(1).optional(),
814
+ maxOutputBytes: z.number().int().min(1).max(64 * 1024 * 1024).optional(),
815
+ pty: z.union([z.boolean(), ptySchema]).optional(),
816
+ },
817
+ }, async (args) => toToolResult(async () => {
818
+ const pty = compactExecPty(args.pty);
819
+ const config = runtimeConfig.get();
820
+ return {
821
+ ok: true,
822
+ result: await sessions.get(args.sessionId).exec({
823
+ command: args.command,
824
+ stdinEncoding: args.stdinEncoding,
825
+ outputEncoding: args.outputEncoding,
826
+ timeoutMs: args.timeoutMs ?? config.defaultExecTimeoutMs,
827
+ maxOutputBytes: args.maxOutputBytes ?? config.defaultMaxOutputBytes,
828
+ ...(args.env === undefined ? {} : { env: args.env }),
829
+ ...(args.stdin === undefined ? {} : { stdin: args.stdin }),
830
+ ...(pty === undefined ? {} : { pty }),
831
+ }),
832
+ };
833
+ }));
834
+ registerTool("ssh_rekey", {
835
+ title: "Rekey SSH session",
836
+ description: "Request an SSH transport rekey for long-lived sessions.",
837
+ inputSchema: { sessionId: sessionIdSchema },
838
+ }, async (args) => toToolResult(async () => sessions.get(args.sessionId).rekey()));
839
+ registerTool("ssh_shell_open", {
840
+ title: "Open interactive shell",
841
+ description: "Open a persistent interactive shell channel with an optional pseudo-terminal.",
842
+ inputSchema: {
843
+ sessionId: sessionIdSchema,
844
+ env: z.record(z.string()).optional(),
845
+ allocatePty: z.boolean().optional(),
846
+ pty: ptySchema.optional(),
847
+ ringBufferBytes: z.number().int().min(1024).max(64 * 1024 * 1024).optional(),
848
+ },
849
+ }, async (args) => toToolResult(async () => {
850
+ const pty = compactPty(args.pty);
851
+ const config = runtimeConfig.get();
852
+ return {
853
+ ok: true,
854
+ shell: await sessions.get(args.sessionId).openShell({
855
+ allocatePty: args.allocatePty ?? config.defaultShellAllocatePty,
856
+ ringBufferBytes: args.ringBufferBytes ?? config.defaultShellRingBufferBytes,
857
+ ...(args.env === undefined ? {} : { env: args.env }),
858
+ ...(pty === undefined ? {} : { pty }),
859
+ }),
860
+ };
861
+ }));
862
+ registerTool("ssh_shell_list", {
863
+ title: "List shells",
864
+ description: "List interactive shell channels in one SSH session.",
865
+ inputSchema: { sessionId: sessionIdSchema },
866
+ }, async (args) => toToolResult(async () => ({ ok: true, shells: sessions.get(args.sessionId).listShells() })));
867
+ registerTool("ssh_shell_write", {
868
+ title: "Write shell input",
869
+ description: "Write UTF-8 or base64 input to a persistent interactive shell.",
870
+ inputSchema: {
871
+ sessionId: sessionIdSchema,
872
+ shellId: z.string().min(1),
873
+ data: z.string(),
874
+ encoding: wireEncodingSchema,
875
+ },
876
+ }, async (args) => toToolResult(async () => ({
877
+ ok: true,
878
+ shell: sessions.get(args.sessionId).writeShell(args.shellId, args.data, args.encoding),
879
+ })));
880
+ registerTool("ssh_shell_read", {
881
+ title: "Read shell output",
882
+ description: "Read buffered output from a persistent interactive shell.",
883
+ inputSchema: {
884
+ sessionId: sessionIdSchema,
885
+ shellId: z.string().min(1),
886
+ maxBytes: z.number().int().min(1).max(64 * 1024 * 1024).optional(),
887
+ encoding: wireEncodingSchema,
888
+ drain: z.boolean().default(true),
889
+ },
890
+ }, async (args) => toToolResult(async () => sessions.get(args.sessionId).readShell({
891
+ shellId: args.shellId,
892
+ maxBytes: args.maxBytes ?? runtimeConfig.get().defaultShellReadMaxBytes,
893
+ encoding: args.encoding,
894
+ drain: args.drain,
895
+ })));
896
+ registerTool("ssh_shell_resize", {
897
+ title: "Resize shell PTY",
898
+ description: "Resize the pseudo-terminal for an interactive shell.",
899
+ inputSchema: {
900
+ sessionId: sessionIdSchema,
901
+ shellId: z.string().min(1),
902
+ rows: z.number().int().min(1),
903
+ cols: z.number().int().min(1),
904
+ height: z.number().int().min(0).default(0),
905
+ width: z.number().int().min(0).default(0),
906
+ },
907
+ }, async (args) => toToolResult(async () => ({
908
+ ok: true,
909
+ shell: sessions.get(args.sessionId).resizeShell(args.shellId, args.rows, args.cols, args.height, args.width),
910
+ })));
911
+ registerTool("ssh_shell_close", {
912
+ title: "Close shell",
913
+ description: "Close an interactive shell channel.",
914
+ inputSchema: { sessionId: sessionIdSchema, shellId: z.string().min(1) },
915
+ }, async (args) => toToolResult(async () => ({ ok: true, shell: sessions.get(args.sessionId).closeShell(args.shellId) })));
916
+ registerTool("sftp_list", {
917
+ title: "List remote directory",
918
+ description: "List SFTP directory entries with type, mode, owner, and size metadata.",
919
+ inputSchema: { sessionId: sessionIdSchema, remotePath: z.string().min(1) },
920
+ }, async (args) => toToolResult(async () => {
921
+ const sftp = await sessions.get(args.sessionId).getSftp();
922
+ return { ok: true, entries: (await sftpReadDir(sftp, args.remotePath)).map(entryToJson) };
923
+ }));
924
+ registerTool("sftp_stat", {
925
+ title: "Stat remote path",
926
+ description: "Get SFTP stat/lstat metadata for a remote path.",
927
+ inputSchema: {
928
+ sessionId: sessionIdSchema,
929
+ remotePath: z.string().min(1),
930
+ followSymlinks: z.boolean().default(true),
931
+ },
932
+ }, async (args) => toToolResult(async () => {
933
+ const sftp = await sessions.get(args.sessionId).getSftp();
934
+ return { ok: true, stats: statsToJson(await sftpStat(sftp, args.remotePath, args.followSymlinks)) };
935
+ }));
936
+ registerTool("sftp_read_file", {
937
+ title: "Read small remote file",
938
+ description: "Read a bounded remote file into memory. Use sftp_download_start for large files.",
939
+ inputSchema: {
940
+ sessionId: sessionIdSchema,
941
+ remotePath: z.string().min(1),
942
+ encoding: wireEncodingSchema,
943
+ maxBytes: z.number().int().min(1).max(64 * 1024 * 1024).optional(),
944
+ },
945
+ }, async (args) => toToolResult(async () => {
946
+ const sftp = await sessions.get(args.sessionId).getSftp();
947
+ return {
948
+ ok: true,
949
+ file: await sftpReadFileLimited(sftp, args.remotePath, args.encoding, args.maxBytes ?? runtimeConfig.get().defaultSftpReadMaxBytes),
950
+ };
951
+ }));
952
+ registerTool("sftp_write_file", {
953
+ title: "Write small remote file",
954
+ description: "Write UTF-8 or base64 data to a remote file. Use sftp_upload_start for large files.",
955
+ inputSchema: {
956
+ sessionId: sessionIdSchema,
957
+ remotePath: z.string().min(1),
958
+ data: z.string(),
959
+ encoding: wireEncodingSchema,
960
+ flag: z.enum(["w", "wx", "a", "ax"]).default("w"),
961
+ mode: z.number().int().min(0).max(0o7777).optional(),
962
+ },
963
+ }, async (args) => toToolResult(async () => {
964
+ const sftp = await sessions.get(args.sessionId).getSftp();
965
+ const data = args.encoding === "base64" ? Buffer.from(args.data, "base64") : Buffer.from(args.data, "utf8");
966
+ await sftpWriteFile(sftp, args.remotePath, data, args.mode, args.flag);
967
+ return { ok: true, remotePath: args.remotePath, bytesWritten: data.length };
968
+ }));
969
+ registerTool("sftp_mkdir", {
970
+ title: "Create remote directory",
971
+ description: "Create a remote directory, optionally recursively.",
972
+ inputSchema: {
973
+ sessionId: sessionIdSchema,
974
+ remotePath: z.string().min(1),
975
+ recursive: z.boolean().default(false),
976
+ mode: modeSchema.optional(),
977
+ },
978
+ }, async (args) => toToolResult(async () => {
979
+ const sftp = await sessions.get(args.sessionId).getSftp();
980
+ const attrs = args.mode === undefined ? undefined : { mode: args.mode };
981
+ if (args.recursive) {
982
+ await sftpMkdirRecursive(sftp, args.remotePath, attrs);
983
+ }
984
+ else {
985
+ await sftpMkdir(sftp, args.remotePath, attrs);
986
+ }
987
+ return { ok: true, remotePath: args.remotePath };
988
+ }));
989
+ registerTool("sftp_rm", {
990
+ title: "Remove remote path",
991
+ description: "Remove a remote file, symlink, or directory. Directories require recursive=true.",
992
+ inputSchema: {
993
+ sessionId: sessionIdSchema,
994
+ remotePath: z.string().min(1),
995
+ recursive: z.boolean().default(false),
996
+ },
997
+ }, async (args) => toToolResult(async () => {
998
+ const sftp = await sessions.get(args.sessionId).getSftp();
999
+ await sftpRemove(sftp, args.remotePath, args.recursive);
1000
+ return { ok: true, remotePath: args.remotePath };
1001
+ }));
1002
+ registerTool("sftp_rename", {
1003
+ title: "Rename remote path",
1004
+ description: "Rename or move a remote path.",
1005
+ inputSchema: {
1006
+ sessionId: sessionIdSchema,
1007
+ sourcePath: z.string().min(1),
1008
+ destinationPath: z.string().min(1),
1009
+ },
1010
+ }, async (args) => toToolResult(async () => {
1011
+ const sftp = await sessions.get(args.sessionId).getSftp();
1012
+ await sftpRename(sftp, args.sourcePath, args.destinationPath);
1013
+ return { ok: true, sourcePath: args.sourcePath, destinationPath: args.destinationPath };
1014
+ }));
1015
+ registerTool("sftp_chmod", {
1016
+ title: "Change remote mode",
1017
+ description: "Change permissions on a remote path.",
1018
+ inputSchema: { sessionId: sessionIdSchema, remotePath: z.string().min(1), mode: modeSchema },
1019
+ }, async (args) => toToolResult(async () => {
1020
+ const sftp = await sessions.get(args.sessionId).getSftp();
1021
+ await sftpChmod(sftp, args.remotePath, args.mode);
1022
+ return { ok: true, remotePath: args.remotePath, mode: args.mode };
1023
+ }));
1024
+ registerTool("sftp_chown", {
1025
+ title: "Change remote owner",
1026
+ description: "Change uid/gid ownership on a remote path.",
1027
+ inputSchema: {
1028
+ sessionId: sessionIdSchema,
1029
+ remotePath: z.string().min(1),
1030
+ uid: z.number().int().min(0),
1031
+ gid: z.number().int().min(0),
1032
+ },
1033
+ }, async (args) => toToolResult(async () => {
1034
+ const sftp = await sessions.get(args.sessionId).getSftp();
1035
+ await sftpChown(sftp, args.remotePath, args.uid, args.gid);
1036
+ return { ok: true, remotePath: args.remotePath, uid: args.uid, gid: args.gid };
1037
+ }));
1038
+ registerTool("sftp_touch", {
1039
+ title: "Touch remote path",
1040
+ description: "Set remote atime/mtime on a path.",
1041
+ inputSchema: {
1042
+ sessionId: sessionIdSchema,
1043
+ remotePath: z.string().min(1),
1044
+ atimeIso: z.string().datetime().optional(),
1045
+ mtimeIso: z.string().datetime().optional(),
1046
+ },
1047
+ }, async (args) => toToolResult(async () => {
1048
+ const sftp = await sessions.get(args.sessionId).getSftp();
1049
+ const now = new Date();
1050
+ const atime = args.atimeIso === undefined ? now : new Date(args.atimeIso);
1051
+ const mtime = args.mtimeIso === undefined ? now : new Date(args.mtimeIso);
1052
+ await sftpUtimes(sftp, args.remotePath, atime, mtime);
1053
+ return { ok: true, remotePath: args.remotePath, atime: atime.toISOString(), mtime: mtime.toISOString() };
1054
+ }));
1055
+ registerTool("sftp_symlink", {
1056
+ title: "Create remote symlink",
1057
+ description: "Create a symbolic link on the remote host.",
1058
+ inputSchema: {
1059
+ sessionId: sessionIdSchema,
1060
+ targetPath: z.string().min(1),
1061
+ linkPath: z.string().min(1),
1062
+ },
1063
+ }, async (args) => toToolResult(async () => {
1064
+ const sftp = await sessions.get(args.sessionId).getSftp();
1065
+ await sftpSymlink(sftp, args.targetPath, args.linkPath);
1066
+ return { ok: true, targetPath: args.targetPath, linkPath: args.linkPath };
1067
+ }));
1068
+ registerTool("sftp_readlink", {
1069
+ title: "Read remote symlink",
1070
+ description: "Read a symbolic link target on the remote host.",
1071
+ inputSchema: { sessionId: sessionIdSchema, remotePath: z.string().min(1) },
1072
+ }, async (args) => toToolResult(async () => {
1073
+ const sftp = await sessions.get(args.sessionId).getSftp();
1074
+ return { ok: true, remotePath: args.remotePath, targetPath: await sftpReadlink(sftp, args.remotePath) };
1075
+ }));
1076
+ registerTool("sftp_realpath", {
1077
+ title: "Resolve remote path",
1078
+ description: "Resolve a remote path to an absolute canonical path.",
1079
+ inputSchema: { sessionId: sessionIdSchema, remotePath: z.string().min(1) },
1080
+ }, async (args) => toToolResult(async () => {
1081
+ const sftp = await sessions.get(args.sessionId).getSftp();
1082
+ return { ok: true, remotePath: args.remotePath, realPath: await sftpRealpath(sftp, args.remotePath) };
1083
+ }));
1084
+ registerTool("sftp_upload_start", {
1085
+ title: "Start large upload",
1086
+ description: "Start a background SFTP upload job with progress, cancellation, and resume support.",
1087
+ inputSchema: {
1088
+ sessionId: sessionIdSchema,
1089
+ localPath: z.string().min(1),
1090
+ remotePath: z.string().min(1),
1091
+ resume: z.boolean().optional(),
1092
+ overwrite: z.boolean().optional(),
1093
+ atomic: z.boolean().optional(),
1094
+ remoteTempPath: z.string().min(1).optional(),
1095
+ mode: z.number().int().min(0).max(0o7777).optional(),
1096
+ chunkSizeBytes: z.number().int().min(16 * 1024).max(16 * 1024 * 1024).optional(),
1097
+ },
1098
+ }, async (args) => toToolResult(async () => {
1099
+ assertNonEmpty(args.localPath, "localPath");
1100
+ assertNonEmpty(args.remotePath, "remotePath");
1101
+ const session = sessions.get(args.sessionId);
1102
+ const config = runtimeConfig.get();
1103
+ return {
1104
+ ok: true,
1105
+ transfer: transfers.startUpload(session, {
1106
+ sessionId: args.sessionId,
1107
+ localPath: args.localPath,
1108
+ remotePath: args.remotePath,
1109
+ resume: args.resume ?? config.defaultTransferResume,
1110
+ overwrite: args.overwrite ?? config.defaultTransferOverwrite,
1111
+ atomic: args.atomic ?? config.defaultTransferAtomic,
1112
+ chunkSizeBytes: args.chunkSizeBytes ?? config.defaultTransferChunkSizeBytes,
1113
+ ...(args.remoteTempPath === undefined ? {} : { remoteTempPath: args.remoteTempPath }),
1114
+ ...(args.mode === undefined ? {} : { mode: args.mode }),
1115
+ }),
1116
+ };
1117
+ }));
1118
+ registerTool("sftp_upload_profile", {
1119
+ title: "Upload to SSH profile",
1120
+ description: "Agent-friendly upload: connect to a named profile and start a resumable background SFTP upload job.",
1121
+ inputSchema: {
1122
+ profileName: z.string().min(1),
1123
+ localPath: z.string().min(1),
1124
+ remotePath: z.string().min(1),
1125
+ resume: z.boolean().optional(),
1126
+ overwrite: z.boolean().optional(),
1127
+ atomic: z.boolean().optional(),
1128
+ remoteTempPath: z.string().min(1).optional(),
1129
+ mode: z.number().int().min(0).max(0o7777).optional(),
1130
+ chunkSizeBytes: z.number().int().min(16 * 1024).max(16 * 1024 * 1024).optional(),
1131
+ },
1132
+ }, async (args) => toToolResult(async () => {
1133
+ assertNonEmpty(args.localPath, "localPath");
1134
+ assertNonEmpty(args.remotePath, "remotePath");
1135
+ const config = runtimeConfig.get();
1136
+ const connected = await connectProfileSession({
1137
+ profileName: args.profileName,
1138
+ keyboardResponses: [],
1139
+ strictVendor: true,
1140
+ debug: false,
1141
+ });
1142
+ const session = sessions.get(connected.session.id);
1143
+ return {
1144
+ ok: true,
1145
+ profile: connected.profile,
1146
+ session: connected.session,
1147
+ transfer: transfers.startUpload(session, {
1148
+ sessionId: connected.session.id,
1149
+ localPath: args.localPath,
1150
+ remotePath: args.remotePath,
1151
+ resume: args.resume ?? config.defaultTransferResume,
1152
+ overwrite: args.overwrite ?? config.defaultTransferOverwrite,
1153
+ atomic: args.atomic ?? config.defaultTransferAtomic,
1154
+ chunkSizeBytes: args.chunkSizeBytes ?? config.defaultTransferChunkSizeBytes,
1155
+ ...(args.remoteTempPath === undefined ? {} : { remoteTempPath: args.remoteTempPath }),
1156
+ ...(args.mode === undefined ? {} : { mode: args.mode }),
1157
+ }),
1158
+ };
1159
+ }));
1160
+ registerTool("sftp_download_start", {
1161
+ title: "Start large download",
1162
+ description: "Start a background SFTP download job with progress, cancellation, and resume support.",
1163
+ inputSchema: {
1164
+ sessionId: sessionIdSchema,
1165
+ remotePath: z.string().min(1),
1166
+ localPath: z.string().min(1),
1167
+ resume: z.boolean().optional(),
1168
+ overwrite: z.boolean().optional(),
1169
+ atomic: z.boolean().optional(),
1170
+ localTempPath: z.string().min(1).optional(),
1171
+ chunkSizeBytes: z.number().int().min(16 * 1024).max(16 * 1024 * 1024).optional(),
1172
+ },
1173
+ }, async (args) => toToolResult(async () => {
1174
+ assertNonEmpty(args.localPath, "localPath");
1175
+ assertNonEmpty(args.remotePath, "remotePath");
1176
+ const session = sessions.get(args.sessionId);
1177
+ const config = runtimeConfig.get();
1178
+ return {
1179
+ ok: true,
1180
+ transfer: transfers.startDownload(session, {
1181
+ sessionId: args.sessionId,
1182
+ remotePath: args.remotePath,
1183
+ localPath: args.localPath,
1184
+ resume: args.resume ?? config.defaultTransferResume,
1185
+ overwrite: args.overwrite ?? config.defaultTransferOverwrite,
1186
+ atomic: args.atomic ?? config.defaultTransferAtomic,
1187
+ chunkSizeBytes: args.chunkSizeBytes ?? config.defaultTransferChunkSizeBytes,
1188
+ ...(args.localTempPath === undefined ? {} : { localTempPath: args.localTempPath }),
1189
+ }),
1190
+ };
1191
+ }));
1192
+ registerTool("sftp_download_profile", {
1193
+ title: "Download from SSH profile",
1194
+ description: "Agent-friendly download: connect to a named profile and start a resumable background SFTP download job.",
1195
+ inputSchema: {
1196
+ profileName: z.string().min(1),
1197
+ remotePath: z.string().min(1),
1198
+ localPath: z.string().min(1),
1199
+ resume: z.boolean().optional(),
1200
+ overwrite: z.boolean().optional(),
1201
+ atomic: z.boolean().optional(),
1202
+ localTempPath: z.string().min(1).optional(),
1203
+ chunkSizeBytes: z.number().int().min(16 * 1024).max(16 * 1024 * 1024).optional(),
1204
+ },
1205
+ }, async (args) => toToolResult(async () => {
1206
+ assertNonEmpty(args.localPath, "localPath");
1207
+ assertNonEmpty(args.remotePath, "remotePath");
1208
+ const config = runtimeConfig.get();
1209
+ const connected = await connectProfileSession({
1210
+ profileName: args.profileName,
1211
+ keyboardResponses: [],
1212
+ strictVendor: true,
1213
+ debug: false,
1214
+ });
1215
+ const session = sessions.get(connected.session.id);
1216
+ return {
1217
+ ok: true,
1218
+ profile: connected.profile,
1219
+ session: connected.session,
1220
+ transfer: transfers.startDownload(session, {
1221
+ sessionId: connected.session.id,
1222
+ remotePath: args.remotePath,
1223
+ localPath: args.localPath,
1224
+ resume: args.resume ?? config.defaultTransferResume,
1225
+ overwrite: args.overwrite ?? config.defaultTransferOverwrite,
1226
+ atomic: args.atomic ?? config.defaultTransferAtomic,
1227
+ chunkSizeBytes: args.chunkSizeBytes ?? config.defaultTransferChunkSizeBytes,
1228
+ ...(args.localTempPath === undefined ? {} : { localTempPath: args.localTempPath }),
1229
+ }),
1230
+ };
1231
+ }));
1232
+ registerTool("sftp_transfer_status", {
1233
+ title: "Get transfer status",
1234
+ description: "Get progress, ETA, and final state for a background upload/download job.",
1235
+ inputSchema: { transferId: z.string().min(1) },
1236
+ }, async (args) => toToolResult(async () => ({ ok: true, transfer: transfers.get(args.transferId) })));
1237
+ registerTool("sftp_transfer_list", {
1238
+ title: "List transfers",
1239
+ description: "List background transfer jobs, optionally filtered by SSH session.",
1240
+ inputSchema: { sessionId: sessionIdSchema.optional() },
1241
+ }, async (args) => toToolResult(async () => ({ ok: true, transfers: transfers.list(args.sessionId) })));
1242
+ registerTool("sftp_transfer_cancel", {
1243
+ title: "Cancel transfer",
1244
+ description: "Cancel a running or queued background transfer job.",
1245
+ inputSchema: { transferId: z.string().min(1) },
1246
+ }, async (args) => toToolResult(async () => ({ ok: true, transfer: transfers.cancel(args.transferId) })));
1247
+ registerTool("ssh_tunnel_local_start", {
1248
+ title: "Start local port forward",
1249
+ description: "Listen locally and forward accepted TCP connections through SSH to a remote target.",
1250
+ inputSchema: {
1251
+ sessionId: sessionIdSchema,
1252
+ localHost: z.string().min(1).optional(),
1253
+ localPort: positivePortSchema.default(0),
1254
+ targetHost: z.string().min(1),
1255
+ targetPort: z.number().int().min(1).max(65_535),
1256
+ },
1257
+ }, async (args) => toToolResult(async () => ({
1258
+ ok: true,
1259
+ tunnel: await sessions.get(args.sessionId).startLocalForward({
1260
+ localHost: args.localHost ?? runtimeConfig.get().defaultLocalTunnelHost,
1261
+ localPort: args.localPort,
1262
+ targetHost: args.targetHost,
1263
+ targetPort: args.targetPort,
1264
+ }),
1265
+ })));
1266
+ registerTool("ssh_tunnel_remote_start", {
1267
+ title: "Start remote port forward",
1268
+ description: "Bind a remote TCP port through SSH and forward incoming connections to a local target.",
1269
+ inputSchema: {
1270
+ sessionId: sessionIdSchema,
1271
+ remoteHost: z.string().min(1).optional(),
1272
+ remotePort: positivePortSchema.default(0),
1273
+ targetHost: z.string().min(1),
1274
+ targetPort: z.number().int().min(1).max(65_535),
1275
+ },
1276
+ }, async (args) => toToolResult(async () => ({
1277
+ ok: true,
1278
+ tunnel: await sessions.get(args.sessionId).startRemoteForward({
1279
+ remoteHost: args.remoteHost ?? runtimeConfig.get().defaultRemoteTunnelHost,
1280
+ remotePort: args.remotePort,
1281
+ targetHost: args.targetHost,
1282
+ targetPort: args.targetPort,
1283
+ }),
1284
+ })));
1285
+ registerTool("ssh_tunnel_list", {
1286
+ title: "List SSH tunnels",
1287
+ description: "List local and remote port forwards for one SSH session.",
1288
+ inputSchema: { sessionId: sessionIdSchema },
1289
+ }, async (args) => toToolResult(async () => ({ ok: true, tunnels: sessions.get(args.sessionId).listTunnels() })));
1290
+ registerTool("ssh_tunnel_stop", {
1291
+ title: "Stop SSH tunnel",
1292
+ description: "Stop a local or remote port forward.",
1293
+ inputSchema: { sessionId: sessionIdSchema, tunnelId: z.string().min(1) },
1294
+ }, async (args) => toToolResult(async () => ({
1295
+ ok: true,
1296
+ tunnel: await sessions.get(args.sessionId).stopTunnel(args.tunnelId),
1297
+ })));
1298
+ const transport = new StdioServerTransport();
1299
+ await server.connect(transport);
1300
+ const shutdown = async () => {
1301
+ await sessions.disconnectAll();
1302
+ };
1303
+ process.once("SIGINT", () => {
1304
+ void shutdown().finally(() => process.exit(0));
1305
+ });
1306
+ process.once("SIGTERM", () => {
1307
+ void shutdown().finally(() => process.exit(0));
1308
+ });
1309
+ async function connectProjectWorkspace(profileName, projectRoot, requireEdit) {
1310
+ const profile = profiles.get(profileName);
1311
+ if (requireEdit) {
1312
+ ensureProfileAllowsEdit(profile);
1313
+ }
1314
+ const connected = await connectProfileSession({
1315
+ profileName,
1316
+ keyboardResponses: [],
1317
+ strictVendor: true,
1318
+ debug: false,
1319
+ });
1320
+ const session = sessions.get(connected.session.id);
1321
+ const sftp = await session.getSftp();
1322
+ try {
1323
+ return {
1324
+ profile,
1325
+ sessionId: connected.session.id,
1326
+ session,
1327
+ sftp,
1328
+ rootPath: await resolveProjectRoot(sftp, profile, projectRoot),
1329
+ };
1330
+ }
1331
+ catch (error) {
1332
+ await sessions.disconnect(connected.session.id);
1333
+ throw error;
1334
+ }
1335
+ }
1336
+ async function inspectRemoteProject(session, rootPath) {
1337
+ const config = runtimeConfig.get();
1338
+ const result = await session.exec({
1339
+ command: buildProjectOpenCommand(rootPath),
1340
+ stdinEncoding: "utf8",
1341
+ outputEncoding: "utf8",
1342
+ timeoutMs: config.defaultExecTimeoutMs,
1343
+ maxOutputBytes: config.defaultMaxOutputBytes,
1344
+ });
1345
+ return {
1346
+ rootPath,
1347
+ result,
1348
+ };
1349
+ }
1350
+ function projectProfileSummary(profile) {
1351
+ return {
1352
+ name: profile.name,
1353
+ aliases: profile.aliases,
1354
+ defaultDir: profile.defaultDir,
1355
+ edit: {
1356
+ allowEdit: profile.allowEdit ?? false,
1357
+ editRoot: profile.editRoot ?? profile.defaultDir,
1358
+ maxEditFileBytes: maxEditFileBytes(profile, runtimeConfig.get().defaultMaxEditFileBytes),
1359
+ backupBeforeEdit: profile.backupBeforeEdit ?? true,
1360
+ backupDir: profile.backupDir,
1361
+ allowDelete: profile.allowDelete ?? false,
1362
+ allowBinaryEdit: profile.allowBinaryEdit ?? false,
1363
+ },
1364
+ };
1365
+ }
1366
+ async function writeProjectFile(workspace, path, data, expectedSha256, createDirs, mode) {
1367
+ const change = await writeProjectFileChange(workspace, path, data, expectedSha256, createDirs, mode);
1368
+ return recordEdit(workspace, [change]);
1369
+ }
1370
+ async function applySinglePatchFile(workspace, path, patchFile, expectedSha256, createDirs) {
1371
+ const patchPath = patchTargetPath(patchFile);
1372
+ if (patchPath !== path && patchPath !== "/dev/null") {
1373
+ throw new McpToolError("ssh_patch_path_mismatch", "Unified patch target does not match requested path", {
1374
+ path,
1375
+ patchPath,
1376
+ });
1377
+ }
1378
+ const currentText = expectedSha256 === "absent" ? "" : await readProjectTextForPatch(workspace, path);
1379
+ const nextText = applyUnifiedPatch(currentText, patchFile);
1380
+ const change = await writeProjectFileChange(workspace, path, Buffer.from(nextText, "utf8"), expectedSha256, createDirs, undefined);
1381
+ return recordEdit(workspace, [change]);
1382
+ }
1383
+ async function validateProjectPatch(workspace, patchFiles, expectedFiles) {
1384
+ const expectedByPath = new Map(expectedFiles.map((file) => [file.path, file.sha256]));
1385
+ const changedFiles = [];
1386
+ const conflicts = [];
1387
+ for (const patchFile of patchFiles) {
1388
+ const path = patchTargetPath(patchFile);
1389
+ try {
1390
+ const expectedSha256 = expectedByPath.get(path);
1391
+ const currentText = expectedSha256 === "absent" || patchFile.isNewFile ? "" : await readProjectTextForPatch(workspace, path);
1392
+ if (expectedSha256 !== undefined && expectedSha256 !== "absent") {
1393
+ const safePath = await resolveSafeRemotePath(workspace.sftp, workspace.profile, workspace.rootPath, path, true);
1394
+ const snapshot = await readRemoteFileSnapshot(workspace.sftp, safePath.remotePath, maxEditFileBytes(workspace.profile, runtimeConfig.get().defaultMaxEditFileBytes));
1395
+ assertExpectedSha256(snapshot, expectedSha256, false, safePath.remotePath);
1396
+ }
1397
+ const nextText = applyUnifiedPatch(currentText, patchFile);
1398
+ changedFiles.push({
1399
+ path,
1400
+ oldBytes: Buffer.byteLength(currentText, "utf8"),
1401
+ newBytes: Buffer.byteLength(nextText, "utf8"),
1402
+ newSha256: sha256(Buffer.from(nextText, "utf8")),
1403
+ });
1404
+ }
1405
+ catch (error) {
1406
+ conflicts.push({ path, error: error instanceof Error ? error.message : String(error) });
1407
+ }
1408
+ }
1409
+ return {
1410
+ applicable: conflicts.length === 0,
1411
+ changedFiles,
1412
+ conflicts,
1413
+ };
1414
+ }
1415
+ async function applyProjectPatch(workspace, patchFiles, expectedFiles, createDirs) {
1416
+ const expectedByPath = new Map(expectedFiles.map((file) => [file.path, file.sha256]));
1417
+ const changes = [];
1418
+ try {
1419
+ for (const patchFile of patchFiles) {
1420
+ const path = patchTargetPath(patchFile);
1421
+ const expectedSha256 = expectedByPath.get(path);
1422
+ if (expectedSha256 === undefined) {
1423
+ throw new McpToolError("ssh_patch_expected_file_missing", "Patch file is missing expectedFiles entry", { path });
1424
+ }
1425
+ const currentText = expectedSha256 === "absent" || patchFile.isNewFile ? "" : await readProjectTextForPatch(workspace, path);
1426
+ const nextText = applyUnifiedPatch(currentText, patchFile);
1427
+ changes.push(await writeProjectFileChange(workspace, path, Buffer.from(nextText, "utf8"), expectedSha256, createDirs, undefined));
1428
+ }
1429
+ }
1430
+ catch (error) {
1431
+ await rollbackChangesBestEffort(workspace, changes);
1432
+ throw error;
1433
+ }
1434
+ return recordEdit(workspace, changes);
1435
+ }
1436
+ async function writeProjectFileChange(workspace, path, data, expectedSha256, createDirs, mode) {
1437
+ const maxBytes = maxEditFileBytes(workspace.profile, runtimeConfig.get().defaultMaxEditFileBytes);
1438
+ if (data.length > maxBytes) {
1439
+ throw new McpToolError("ssh_edit_file_too_large", "New file content exceeds max edit size", {
1440
+ path,
1441
+ bytes: data.length,
1442
+ maxBytes,
1443
+ });
1444
+ }
1445
+ if ((workspace.profile.allowBinaryEdit ?? false) !== true && data.includes(0)) {
1446
+ throw new McpToolError("ssh_binary_edit_disabled", "Binary file editing is disabled for this profile", { path });
1447
+ }
1448
+ const safePath = await resolveSafeRemotePath(workspace.sftp, workspace.profile, workspace.rootPath, path, false);
1449
+ const previous = await readOptionalSnapshot(workspace, safePath.remotePath);
1450
+ assertExpectedSha256(previous, expectedSha256, true, safePath.remotePath);
1451
+ const backup = previous !== undefined && (workspace.profile.backupBeforeEdit ?? true)
1452
+ ? await createRemoteBackup(workspace.sftp, workspace.profile, workspace.rootPath, previous)
1453
+ : undefined;
1454
+ await writeRemoteFileAtomic(workspace.sftp, safePath.remotePath, data, mode ?? (previous === undefined ? undefined : previous.stats.mode & 0o777), createDirs);
1455
+ const next = await readRemoteFileSnapshot(workspace.sftp, safePath.remotePath, maxBytes);
1456
+ return compactChange({
1457
+ path,
1458
+ remotePath: safePath.remotePath,
1459
+ operation: previous === undefined ? "create" : "modify",
1460
+ oldSha256: previous?.sha256,
1461
+ newSha256: next.sha256,
1462
+ bytes: next.data.length,
1463
+ backup,
1464
+ });
1465
+ }
1466
+ async function deleteProjectFile(workspace, path, expectedSha256) {
1467
+ if ((workspace.profile.allowDelete ?? false) !== true) {
1468
+ throw new McpToolError("ssh_profile_delete_disabled", "SSH profile deletion is disabled", {
1469
+ profileName: workspace.profile.name,
1470
+ requiredEnv: `SSH_MCP_SERVER_<N>_ALLOW_DELETE=true`,
1471
+ });
1472
+ }
1473
+ const safePath = await resolveSafeRemotePath(workspace.sftp, workspace.profile, workspace.rootPath, path, true);
1474
+ const previous = await readRemoteFileSnapshot(workspace.sftp, safePath.remotePath, maxEditFileBytes(workspace.profile, runtimeConfig.get().defaultMaxEditFileBytes));
1475
+ assertExpectedSha256(previous, expectedSha256, true, safePath.remotePath);
1476
+ const backup = await createRemoteBackup(workspace.sftp, workspace.profile, workspace.rootPath, previous);
1477
+ await sftpUnlink(workspace.sftp, safePath.remotePath);
1478
+ return recordEdit(workspace, [
1479
+ compactChange({
1480
+ path,
1481
+ remotePath: safePath.remotePath,
1482
+ operation: "delete",
1483
+ oldSha256: previous.sha256,
1484
+ backup,
1485
+ }),
1486
+ ]);
1487
+ }
1488
+ async function moveProjectFile(workspace, fromPath, toPath, expectedSha256, createDirs) {
1489
+ const from = await resolveSafeRemotePath(workspace.sftp, workspace.profile, workspace.rootPath, fromPath, true);
1490
+ const to = await resolveSafeRemotePath(workspace.sftp, workspace.profile, workspace.rootPath, toPath, false);
1491
+ const previous = await readRemoteFileSnapshot(workspace.sftp, from.remotePath, maxEditFileBytes(workspace.profile, runtimeConfig.get().defaultMaxEditFileBytes));
1492
+ assertExpectedSha256(previous, expectedSha256, true, from.remotePath);
1493
+ if (createDirs) {
1494
+ await sftpMkdirRecursive(workspace.sftp, dirnameFromRemotePath(to.remotePath));
1495
+ }
1496
+ if (await remotePathExists(workspace, to.remotePath)) {
1497
+ throw new McpToolError("ssh_move_target_exists", "Move target already exists", { toPath, remotePath: to.remotePath });
1498
+ }
1499
+ const backup = await createRemoteBackup(workspace.sftp, workspace.profile, workspace.rootPath, previous);
1500
+ await sftpRename(workspace.sftp, from.remotePath, to.remotePath);
1501
+ return recordEdit(workspace, [
1502
+ compactChange({
1503
+ path: fromPath,
1504
+ remotePath: from.remotePath,
1505
+ operation: "move",
1506
+ oldSha256: previous.sha256,
1507
+ newSha256: previous.sha256,
1508
+ bytes: previous.data.length,
1509
+ backup,
1510
+ movedToPath: toPath,
1511
+ movedToRemotePath: to.remotePath,
1512
+ }),
1513
+ ]);
1514
+ }
1515
+ async function rollbackEdit(workspace, edit) {
1516
+ if (edit.profileName !== workspace.profile.name) {
1517
+ throw new McpToolError("ssh_edit_profile_mismatch", "Edit record belongs to a different profile", {
1518
+ editId: edit.editId,
1519
+ expectedProfile: edit.profileName,
1520
+ actualProfile: workspace.profile.name,
1521
+ });
1522
+ }
1523
+ if (edit.rolledBackAt !== undefined) {
1524
+ throw new McpToolError("ssh_edit_already_rolled_back", "Edit has already been rolled back", {
1525
+ editId: edit.editId,
1526
+ rolledBackAt: edit.rolledBackAt,
1527
+ });
1528
+ }
1529
+ const rollbackChanges = [];
1530
+ for (const change of [...edit.changes].reverse()) {
1531
+ if (change.operation === "create") {
1532
+ const current = await readOptionalSnapshot(workspace, change.remotePath);
1533
+ if (current !== undefined) {
1534
+ assertExpectedSha256(current, change.newSha256, false, change.remotePath);
1535
+ await sftpUnlink(workspace.sftp, change.remotePath);
1536
+ }
1537
+ rollbackChanges.push({ ...change, operation: "delete", oldSha256: change.newSha256, newSha256: undefined });
1538
+ continue;
1539
+ }
1540
+ if (change.operation === "move" && change.movedToRemotePath !== undefined) {
1541
+ const moved = await readOptionalSnapshot(workspace, change.movedToRemotePath);
1542
+ if (moved !== undefined) {
1543
+ assertExpectedSha256(moved, change.newSha256, false, change.movedToRemotePath);
1544
+ await sftpUnlink(workspace.sftp, change.movedToRemotePath);
1545
+ }
1546
+ }
1547
+ if (change.backup === undefined) {
1548
+ throw new McpToolError("ssh_edit_backup_missing", "Edit record has no backup for rollback", {
1549
+ editId: edit.editId,
1550
+ path: change.path,
1551
+ });
1552
+ }
1553
+ const backup = await readRemoteFileSnapshot(workspace.sftp, change.backup.backupPath, maxEditFileBytes(workspace.profile, runtimeConfig.get().defaultMaxEditFileBytes));
1554
+ await writeRemoteFileAtomic(workspace.sftp, change.remotePath, backup.data, undefined, true);
1555
+ rollbackChanges.push({
1556
+ path: change.path,
1557
+ remotePath: change.remotePath,
1558
+ operation: "modify",
1559
+ oldSha256: change.newSha256,
1560
+ newSha256: backup.sha256,
1561
+ bytes: backup.data.length,
1562
+ });
1563
+ }
1564
+ edit.rolledBackAt = new Date().toISOString();
1565
+ return recordEdit(workspace, rollbackChanges);
1566
+ }
1567
+ async function readProjectTextForPatch(workspace, path) {
1568
+ const safePath = await resolveSafeRemotePath(workspace.sftp, workspace.profile, workspace.rootPath, path, true);
1569
+ const snapshot = await readRemoteFileSnapshot(workspace.sftp, safePath.remotePath, maxEditFileBytes(workspace.profile, runtimeConfig.get().defaultMaxEditFileBytes));
1570
+ if ((workspace.profile.allowBinaryEdit ?? false) !== true && snapshot.data.includes(0)) {
1571
+ throw new McpToolError("ssh_binary_edit_disabled", "Binary file editing is disabled for this profile", { path });
1572
+ }
1573
+ return snapshot.data.toString("utf8");
1574
+ }
1575
+ async function readOptionalSnapshot(workspace, remotePath) {
1576
+ try {
1577
+ return await readRemoteFileSnapshot(workspace.sftp, remotePath, maxEditFileBytes(workspace.profile, runtimeConfig.get().defaultMaxEditFileBytes));
1578
+ }
1579
+ catch (error) {
1580
+ if (error instanceof McpToolError && /no such file|not found|failure/i.test(error.message)) {
1581
+ return undefined;
1582
+ }
1583
+ throw error;
1584
+ }
1585
+ }
1586
+ async function remotePathExists(workspace, remotePath) {
1587
+ try {
1588
+ await sftpStat(workspace.sftp, remotePath, false);
1589
+ return true;
1590
+ }
1591
+ catch (error) {
1592
+ if (error instanceof McpToolError && /no such file|not found|failure/i.test(error.message)) {
1593
+ return false;
1594
+ }
1595
+ throw error;
1596
+ }
1597
+ }
1598
+ async function resolveProjectPathArgs(workspace, paths, mustExist) {
1599
+ const resolvedPaths = [];
1600
+ for (const path of paths) {
1601
+ const safePath = await resolveSafeRemotePath(workspace.sftp, workspace.profile, workspace.rootPath, path, mustExist);
1602
+ resolvedPaths.push(relativeRemotePath(workspace.rootPath, safePath.remotePath));
1603
+ }
1604
+ return resolvedPaths;
1605
+ }
1606
+ async function rollbackChangesBestEffort(workspace, changes) {
1607
+ for (const change of [...changes].reverse()) {
1608
+ try {
1609
+ if (change.operation === "create") {
1610
+ await sftpUnlink(workspace.sftp, change.remotePath);
1611
+ }
1612
+ else if (change.backup !== undefined) {
1613
+ const backup = await readRemoteFileSnapshot(workspace.sftp, change.backup.backupPath, maxEditFileBytes(workspace.profile, runtimeConfig.get().defaultMaxEditFileBytes));
1614
+ await writeRemoteFileAtomic(workspace.sftp, change.remotePath, backup.data, undefined, true);
1615
+ }
1616
+ }
1617
+ catch {
1618
+ // Best-effort rollback keeps the original apply error as the actionable failure.
1619
+ }
1620
+ }
1621
+ }
1622
+ function recordEdit(workspace, changes) {
1623
+ const record = {
1624
+ editId: randomUUID(),
1625
+ profileName: workspace.profile.name,
1626
+ projectRoot: workspace.rootPath,
1627
+ createdAt: new Date().toISOString(),
1628
+ changes,
1629
+ };
1630
+ editHistory.set(record.editId, record);
1631
+ return record;
1632
+ }
1633
+ function compactChange(change) {
1634
+ return Object.fromEntries(Object.entries(change).filter(([, value]) => value !== undefined));
1635
+ }
1636
+ function assertPatchSize(patch) {
1637
+ const bytes = Buffer.byteLength(patch, "utf8");
1638
+ const maxBytes = runtimeConfig.get().defaultMaxPatchBytes;
1639
+ if (bytes > maxBytes) {
1640
+ throw new McpToolError("ssh_patch_too_large", "Unified patch exceeds max patch size", { bytes, maxBytes });
1641
+ }
1642
+ }
1643
+ function compactTreeOptions(maxDepth, maxEntries, includeHidden, ignoreNames) {
1644
+ return {
1645
+ ...(maxDepth === undefined ? {} : { maxDepth }),
1646
+ ...(maxEntries === undefined ? {} : { maxEntries }),
1647
+ ...(includeHidden === undefined ? {} : { includeHidden }),
1648
+ ...(ignoreNames === undefined ? {} : { ignoreNames }),
1649
+ };
1650
+ }
1651
+ function buildProjectOpenCommand(rootPath) {
1652
+ return buildRemoteCommand([
1653
+ "printf 'pwd: '; pwd",
1654
+ "printf 'git_root: '; git rev-parse --show-toplevel 2>/dev/null || true",
1655
+ "printf 'git_branch: '; git branch --show-current 2>/dev/null || true",
1656
+ "printf 'node_package: '; test -f package.json && echo yes || echo no",
1657
+ "printf 'python_project: '; test -f pyproject.toml -o -f requirements.txt && echo yes || echo no",
1658
+ "printf 'go_project: '; test -f go.mod && echo yes || echo no",
1659
+ "printf 'rust_project: '; test -f Cargo.toml && echo yes || echo no",
1660
+ ].join("; "), rootPath);
1661
+ }
1662
+ function buildProjectSearchCommand(rootPath, query, paths, literal, maxResults, contextLines) {
1663
+ const rgLiteral = literal ? "--fixed-strings" : "";
1664
+ const grepLiteral = literal ? "-F" : "-E";
1665
+ const quotedQuery = quotePosixShellArg(query);
1666
+ const quotedPaths = paths.length === 0 ? "." : paths.map(quotePosixShellArg).join(" ");
1667
+ return buildRemoteCommand(`if command -v rg >/dev/null 2>&1; then rg -n --color never ${rgLiteral} -C ${contextLines} -m ${maxResults} -- ${quotedQuery} ${quotedPaths}; else grep -RIn ${grepLiteral} -C ${contextLines} -- ${quotedQuery} ${quotedPaths} | head -n ${maxResults}; fi`, rootPath);
1668
+ }
1669
+ function buildProjectDiffCommand(rootPath, paths) {
1670
+ const quotedPaths = paths.length === 0 ? "" : ` -- ${paths.map(quotePosixShellArg).join(" ")}`;
1671
+ return buildRemoteCommand(`if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then printf '## status\\n'; git status --short${quotedPaths}; printf '\\n## diff\\n'; git diff --no-ext-diff --src-prefix=a/ --dst-prefix=b/${quotedPaths}; else echo 'not a git repository'; fi`, rootPath);
1672
+ }
1673
+ function parseSearchMatches(output) {
1674
+ return output
1675
+ .split(/\r?\n/)
1676
+ .filter((line) => line.length > 0)
1677
+ .map((line) => {
1678
+ const match = /^(?<path>.*?):(?<line>\d+):(?<text>.*)$/.exec(line);
1679
+ if (match?.groups === undefined) {
1680
+ return { path: "", text: line };
1681
+ }
1682
+ return {
1683
+ path: match.groups.path ?? "",
1684
+ line: Number.parseInt(match.groups.line ?? "0", 10),
1685
+ text: match.groups.text ?? "",
1686
+ };
1687
+ });
1688
+ }
1689
+ function dirnameFromRemotePath(remotePath) {
1690
+ const parts = remotePath.split("/");
1691
+ parts.pop();
1692
+ const dirname = parts.join("/");
1693
+ return dirname.length === 0 ? "." : dirname;
1694
+ }
1695
+ function relativeRemotePath(rootPath, remotePath) {
1696
+ if (remotePath === rootPath) {
1697
+ return ".";
1698
+ }
1699
+ if (remotePath.startsWith(`${rootPath}/`)) {
1700
+ return remotePath.slice(rootPath.length + 1);
1701
+ }
1702
+ return remotePath;
1703
+ }
1704
+ function normalizeProfileNameForHistory(profileName) {
1705
+ return profileName.trim().replace(/[\s-]+/g, "_").toUpperCase();
1706
+ }
1707
+ async function connectProfileSession(input) {
1708
+ const profile = profiles.get(input.profileName);
1709
+ const config = runtimeConfig.get();
1710
+ const session = await sessions.connect({
1711
+ host: profile.host,
1712
+ port: profile.port,
1713
+ username: profile.username,
1714
+ name: input.sessionName ?? profile.name,
1715
+ agentForward: input.agentForward ?? config.defaultAgentForward,
1716
+ tryKeyboard: input.tryKeyboard ?? config.defaultTryKeyboard,
1717
+ keyboardResponses: input.keyboardResponses,
1718
+ keepaliveIntervalMs: input.keepaliveIntervalMs ?? config.defaultKeepaliveIntervalMs,
1719
+ keepaliveCountMax: input.keepaliveCountMax ?? config.defaultKeepaliveCountMax,
1720
+ readyTimeoutMs: input.readyTimeoutMs ?? config.defaultReadyTimeoutMs,
1721
+ connectionTimeoutMs: input.connectionTimeoutMs ?? config.defaultConnectionTimeoutMs,
1722
+ strictVendor: input.strictVendor,
1723
+ debug: input.debug,
1724
+ ...(profile.password === undefined ? {} : { password: profile.password }),
1725
+ ...(profile.privateKey === undefined ? {} : { privateKey: profile.privateKey }),
1726
+ ...(profile.privateKeyPath === undefined ? {} : { privateKeyPath: profile.privateKeyPath }),
1727
+ ...(profile.passphrase === undefined ? {} : { passphrase: profile.passphrase }),
1728
+ ...(profile.agent === undefined ? {} : { agent: profile.agent }),
1729
+ ...(input.hostHash === undefined ? {} : { hostHash: input.hostHash }),
1730
+ ...(input.expectedHostHash === undefined ? {} : { expectedHostHash: input.expectedHostHash }),
1731
+ });
1732
+ return {
1733
+ profile: {
1734
+ name: profile.name,
1735
+ aliases: profile.aliases,
1736
+ host: profile.host,
1737
+ port: profile.port,
1738
+ username: profile.username,
1739
+ ...(profile.displayName === undefined ? {} : { displayName: profile.displayName }),
1740
+ ...(profile.defaultDir === undefined ? {} : { defaultDir: profile.defaultDir }),
1741
+ ...(profile.description === undefined ? {} : { description: profile.description }),
1742
+ ...(profile.platform === undefined ? {} : { platform: profile.platform }),
1743
+ },
1744
+ session,
1745
+ };
1746
+ }
1747
+ function buildRemoteCommand(command, workingDirectory) {
1748
+ if (workingDirectory === undefined) {
1749
+ return command;
1750
+ }
1751
+ return `cd ${quotePosixShellArg(workingDirectory)} && ${command}`;
1752
+ }
1753
+ function quotePosixShellArg(value) {
1754
+ return `'${value.replaceAll("'", "'\\''")}'`;
1755
+ }
1756
+ async function runRemoteCheck(input) {
1757
+ try {
1758
+ const result = await input.execute(input.command);
1759
+ return {
1760
+ name: input.name,
1761
+ title: input.title,
1762
+ command: input.command,
1763
+ ok: result.exitCode === 0 && !result.timedOut,
1764
+ result,
1765
+ };
1766
+ }
1767
+ catch (error) {
1768
+ return {
1769
+ name: input.name,
1770
+ title: input.title,
1771
+ command: input.command,
1772
+ ok: false,
1773
+ error: error instanceof Error ? error.message : String(error),
1774
+ };
1775
+ }
1776
+ }
1777
+ function compactExecPty(input) {
1778
+ if (typeof input === "boolean" || input === undefined) {
1779
+ return input;
1780
+ }
1781
+ return compactPty(input);
1782
+ }
1783
+ function compactPty(input) {
1784
+ if (input === undefined) {
1785
+ return undefined;
1786
+ }
1787
+ const pty = {};
1788
+ if (input.rows !== undefined) {
1789
+ pty.rows = input.rows;
1790
+ }
1791
+ if (input.cols !== undefined) {
1792
+ pty.cols = input.cols;
1793
+ }
1794
+ if (input.height !== undefined) {
1795
+ pty.height = input.height;
1796
+ }
1797
+ if (input.width !== undefined) {
1798
+ pty.width = input.width;
1799
+ }
1800
+ if (input.term !== undefined) {
1801
+ pty.term = input.term;
1802
+ }
1803
+ return pty;
1804
+ }
1805
+ //# sourceMappingURL=index.js.map