enterprise-ssh-mcp-server 0.3.0 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/README.md +97 -0
  3. package/dist/app/create-app.d.ts +4 -0
  4. package/dist/app/create-app.js +38 -0
  5. package/dist/app/create-app.js.map +1 -0
  6. package/dist/app/tool-context.d.ts +258 -0
  7. package/dist/app/tool-context.js +683 -0
  8. package/dist/app/tool-context.js.map +1 -0
  9. package/dist/env-profiles.d.ts +1 -0
  10. package/dist/env-profiles.js +6 -2
  11. package/dist/env-profiles.js.map +1 -1
  12. package/dist/env-reload.d.ts +36 -0
  13. package/dist/env-reload.js +65 -0
  14. package/dist/env-reload.js.map +1 -0
  15. package/dist/host-ca-trust-config.d.ts +10 -0
  16. package/dist/host-ca-trust-config.js +43 -0
  17. package/dist/host-ca-trust-config.js.map +1 -0
  18. package/dist/index.js +5 -1798
  19. package/dist/index.js.map +1 -1
  20. package/dist/openssh-command.d.ts +32 -0
  21. package/dist/openssh-command.js +207 -0
  22. package/dist/openssh-command.js.map +1 -0
  23. package/dist/openssh-temporary.d.ts +23 -0
  24. package/dist/openssh-temporary.js +81 -0
  25. package/dist/openssh-temporary.js.map +1 -0
  26. package/dist/openssh-trust.d.ts +15 -0
  27. package/dist/openssh-trust.js +91 -0
  28. package/dist/openssh-trust.js.map +1 -0
  29. package/dist/runtime-config.d.ts +1 -0
  30. package/dist/runtime-config.js +10 -1
  31. package/dist/runtime-config.js.map +1 -1
  32. package/dist/ssh-host-key.d.ts +27 -0
  33. package/dist/ssh-host-key.js +235 -0
  34. package/dist/ssh-host-key.js.map +1 -0
  35. package/dist/ssh-session.js +7 -3
  36. package/dist/ssh-session.js.map +1 -1
  37. package/dist/ssh-target.d.ts +19 -0
  38. package/dist/ssh-target.js +58 -0
  39. package/dist/ssh-target.js.map +1 -0
  40. package/dist/tools/register-config-profile-tools.d.ts +2 -0
  41. package/dist/tools/register-config-profile-tools.js +179 -0
  42. package/dist/tools/register-config-profile-tools.js.map +1 -0
  43. package/dist/tools/register-project-tools.d.ts +2 -0
  44. package/dist/tools/register-project-tools.js +377 -0
  45. package/dist/tools/register-project-tools.js.map +1 -0
  46. package/dist/tools/register-session-tools.d.ts +2 -0
  47. package/dist/tools/register-session-tools.js +293 -0
  48. package/dist/tools/register-session-tools.js.map +1 -0
  49. package/dist/tools/register-sftp-tools.d.ts +2 -0
  50. package/dist/tools/register-sftp-tools.js +172 -0
  51. package/dist/tools/register-sftp-tools.js.map +1 -0
  52. package/dist/tools/register-transfer-tools.d.ts +2 -0
  53. package/dist/tools/register-transfer-tools.js +167 -0
  54. package/dist/tools/register-transfer-tools.js.map +1 -0
  55. package/dist/tools/register-tunnel-tools.d.ts +2 -0
  56. package/dist/tools/register-tunnel-tools.js +55 -0
  57. package/dist/tools/register-tunnel-tools.js.map +1 -0
  58. package/dist/tools/tool-metadata.d.ts +18 -0
  59. package/dist/tools/tool-metadata.js +222 -0
  60. package/dist/tools/tool-metadata.js.map +1 -0
  61. package/package.json +5 -3
@@ -0,0 +1,683 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
+ import { z } from "zod";
4
+ import { defaultEnvPath, ProfileRegistry } from "../env-profiles.js";
5
+ import { reloadEnvSnapshot } from "../env-reload.js";
6
+ import { RuntimeConfigRegistry } from "../runtime-config.js";
7
+ import { SessionRegistry } from "../session-registry.js";
8
+ import { entryToJson, sftpChmod, sftpChown, sftpMkdir, sftpMkdirRecursive, sftpReadFileLimited, sftpReadDir, sftpReadlink, sftpRealpath, sftpRemove, sftpRename, sftpStat, sftpSymlink, sftpUnlink, sftpUtimes, sftpWriteFile, statsToJson, } from "../sftp-ops.js";
9
+ import { TransferManager } from "../transfer-manager.js";
10
+ import { assertNonEmpty, McpToolError, setCompactJsonResponse, toToolResult } from "../tool-result.js";
11
+ import { applyUnifiedPatch, assertExpectedSha256, createRemoteBackup, defaultTreeOptions, ensureProfileAllowsEdit, maxEditFileBytes, parseUnifiedPatch, patchTargetPath, publicSnapshot, readRemoteFileSnapshot, readRemoteTree, resolveProjectRoot, resolveSafeRemotePath, sha256, writeRemoteFileAtomic, } from "../remote-edit.js";
12
+ import { AGENT_TOOL_NAMES, DANGEROUS_TOOL_NAMES, resolveToolAnnotations } from "../tools/tool-metadata.js";
13
+ const SERVER_INSTRUCTIONS = [
14
+ "This server provides SSH/SFTP tools for configured remote hosts.",
15
+ "For a new host that is not saved as a Profile, use ssh_connect for a temporary direct connection; host may be a hostname/IP with a separate username, or username@hostname.",
16
+ "A host and port identify the destination but do not by themselves authenticate the user; use the authentication mechanism required by the server.",
17
+ "For InstaBox SSH tokens, the ibssh_… token is the SSH username itself; it needs no password or private key. Treat this username as a bearer secret; responses redact it and it should not be saved in a shared .env file.",
18
+ "The current ssh2 persistent-session backend cannot negotiate InstaBox's OpenSSH host-certificate algorithm. ssh_probe_host_key can inspect the certificate without authentication; ssh_connect refuses this target before sending the token. For a one-shot command, use ssh_run_openssh_temporary only after a host-specific CA fingerprint has been independently verified and pinned in the local MCP process environment variable SSH_MCP_TRUSTED_HOST_CA_FINGERPRINTS. Tool arguments cannot create or override this trust anchor. Call ssh_trust_host_ca only when the CA key matches that local pin and the user wants permanent known_hosts trust; prefer usernameEnvVar to avoid passing a token in tool arguments. Persistent Shell/SFTP/tunnel support for this certificate backend remains future work.",
19
+ "InstaBox's SSH gateway does not support agent forwarding or remote forwarding. Never silently trust an unknown host key or CA fingerprint.",
20
+ "For quick remote inspection or health checks, prefer ssh_check_profile.",
21
+ "For one-shot commands on a configured profile, prefer ssh_run_profile instead of ssh_connect_profile + ssh_exec.",
22
+ "Use ssh_connect_profile plus session tools only when the user explicitly needs a persistent session, shell, SFTP workflow, transfer job, or tunnel.",
23
+ "Do not use local shell commands to simulate remote SSH checks when an SSH profile tool can do the work.",
24
+ "Dangerous tools such as tunnels, rm, chmod, chown, and disconnect_all are hidden unless explicitly enabled by runtime config.",
25
+ ].join(" ");
26
+ const server = new McpServer({
27
+ name: "enterprise-ssh-mcp-server",
28
+ version: "0.3.1",
29
+ }, {
30
+ instructions: SERVER_INSTRUCTIONS,
31
+ });
32
+ const envPath = defaultEnvPath();
33
+ const profiles = new ProfileRegistry(envPath);
34
+ const runtimeConfig = new RuntimeConfigRegistry(envPath);
35
+ setCompactJsonResponse(runtimeConfig.get().compactJson);
36
+ const startupToolExposure = {
37
+ toolset: runtimeConfig.get().toolset,
38
+ enableDangerousTools: runtimeConfig.get().enableDangerousTools,
39
+ };
40
+ const reloadEnvState = () => reloadEnvSnapshot({
41
+ envPath,
42
+ runtimeConfig,
43
+ profiles,
44
+ startupToolExposure,
45
+ setCompactJsonResponse,
46
+ });
47
+ const sessions = new SessionRegistry();
48
+ const transfers = new TransferManager();
49
+ const editHistory = new Map();
50
+ const sessionIdSchema = z.string().min(1);
51
+ const wireEncodingSchema = z.enum(["utf8", "base64"]).default("utf8");
52
+ const positivePortSchema = z.number().int().min(0).max(65_535);
53
+ const modeSchema = z.union([z.number().int().min(0).max(0o7777), z.string().regex(/^[0-7]{3,4}$/)]);
54
+ const ptySchema = z.object({
55
+ rows: z.number().int().min(1).optional(),
56
+ cols: z.number().int().min(1).optional(),
57
+ height: z.number().int().min(0).optional(),
58
+ width: z.number().int().min(0).optional(),
59
+ term: z.string().min(1).optional(),
60
+ });
61
+ const projectRootSchema = z.string().min(1).optional();
62
+ const fileEncodingSchema = z.enum(["utf8", "base64"]).default("utf8");
63
+ const expectedShaSchema = z.union([z.string().length(64), z.literal("absent")]);
64
+ const expectedFileSchema = z.object({
65
+ path: z.string().min(1),
66
+ sha256: expectedShaSchema,
67
+ });
68
+ const projectPathSchema = z.string().min(1);
69
+ const REMOTE_CHECK_NAMES = ["identity", "pwd", "os", "uptime", "disk", "memory", "processes"];
70
+ const remoteCheckNameSchema = z.enum(REMOTE_CHECK_NAMES);
71
+ const DEFAULT_REMOTE_CHECKS = ["identity", "pwd", "os", "uptime", "disk", "memory"];
72
+ const REMOTE_CHECK_COMMANDS = {
73
+ identity: {
74
+ title: "User and host identity",
75
+ command: "printf 'whoami: '; whoami; printf 'hostname: '; hostname; id",
76
+ },
77
+ pwd: {
78
+ title: "Current directory",
79
+ command: "pwd",
80
+ },
81
+ os: {
82
+ title: "Operating system",
83
+ command: "uname -a || ver",
84
+ },
85
+ uptime: {
86
+ title: "Uptime",
87
+ command: "uptime || wmic os get lastbootuptime",
88
+ },
89
+ disk: {
90
+ title: "Disk usage",
91
+ command: "df -h . || wmic logicaldisk get caption,freespace,size",
92
+ },
93
+ memory: {
94
+ title: "Memory usage",
95
+ command: "free -h || vm_stat || wmic OS get FreePhysicalMemory,TotalVisibleMemorySize /Value",
96
+ },
97
+ processes: {
98
+ title: "Top processes",
99
+ command: "ps aux | head -n 15 || tasklist",
100
+ },
101
+ };
102
+ function registerTool(name, config, cb) {
103
+ const activeConfig = runtimeConfig.get();
104
+ if (activeConfig.toolset === "agent" && !AGENT_TOOL_NAMES.has(name)) {
105
+ return undefined;
106
+ }
107
+ if (!activeConfig.enableDangerousTools && DANGEROUS_TOOL_NAMES.has(name)) {
108
+ return undefined;
109
+ }
110
+ return server.registerTool(name, { ...config, annotations: resolveToolAnnotations(name, config.annotations) }, cb);
111
+ }
112
+ async function connectProjectWorkspace(profileName, projectRoot, requireEdit) {
113
+ const profile = profiles.get(profileName);
114
+ if (requireEdit) {
115
+ ensureProfileAllowsEdit(profile);
116
+ }
117
+ const connected = await connectProfileSession({
118
+ profileName,
119
+ keyboardResponses: [],
120
+ strictVendor: true,
121
+ debug: false,
122
+ });
123
+ const session = sessions.get(connected.session.id);
124
+ const sftp = await session.getSftp();
125
+ try {
126
+ return {
127
+ profile,
128
+ sessionId: connected.session.id,
129
+ session,
130
+ sftp,
131
+ rootPath: await resolveProjectRoot(sftp, profile, projectRoot),
132
+ };
133
+ }
134
+ catch (error) {
135
+ await sessions.disconnect(connected.session.id);
136
+ throw error;
137
+ }
138
+ }
139
+ async function inspectRemoteProject(session, rootPath) {
140
+ const config = runtimeConfig.get();
141
+ const result = await session.exec({
142
+ command: buildProjectOpenCommand(rootPath),
143
+ stdinEncoding: "utf8",
144
+ outputEncoding: "utf8",
145
+ timeoutMs: config.defaultExecTimeoutMs,
146
+ maxOutputBytes: config.defaultMaxOutputBytes,
147
+ });
148
+ return {
149
+ rootPath,
150
+ result,
151
+ };
152
+ }
153
+ function projectProfileSummary(profile) {
154
+ return {
155
+ name: profile.name,
156
+ aliases: profile.aliases,
157
+ defaultDir: profile.defaultDir,
158
+ edit: {
159
+ allowEdit: profile.allowEdit ?? false,
160
+ editRoot: profile.editRoot ?? profile.defaultDir,
161
+ maxEditFileBytes: maxEditFileBytes(profile, runtimeConfig.get().defaultMaxEditFileBytes),
162
+ backupBeforeEdit: profile.backupBeforeEdit ?? true,
163
+ backupDir: profile.backupDir,
164
+ allowDelete: profile.allowDelete ?? false,
165
+ allowBinaryEdit: profile.allowBinaryEdit ?? false,
166
+ },
167
+ };
168
+ }
169
+ async function writeProjectFile(workspace, path, data, expectedSha256, createDirs, mode) {
170
+ const change = await writeProjectFileChange(workspace, path, data, expectedSha256, createDirs, mode);
171
+ return recordEdit(workspace, [change]);
172
+ }
173
+ async function applySinglePatchFile(workspace, path, patchFile, expectedSha256, createDirs) {
174
+ const patchPath = patchTargetPath(patchFile);
175
+ if (patchPath !== path && patchPath !== "/dev/null") {
176
+ throw new McpToolError("ssh_patch_path_mismatch", "Unified patch target does not match requested path", {
177
+ path,
178
+ patchPath,
179
+ });
180
+ }
181
+ const currentText = expectedSha256 === "absent" ? "" : await readProjectTextForPatch(workspace, path);
182
+ const nextText = applyUnifiedPatch(currentText, patchFile);
183
+ const change = await writeProjectFileChange(workspace, path, Buffer.from(nextText, "utf8"), expectedSha256, createDirs, undefined);
184
+ return recordEdit(workspace, [change]);
185
+ }
186
+ async function validateProjectPatch(workspace, patchFiles, expectedFiles) {
187
+ const expectedByPath = new Map(expectedFiles.map((file) => [file.path, file.sha256]));
188
+ const changedFiles = [];
189
+ const conflicts = [];
190
+ for (const patchFile of patchFiles) {
191
+ const path = patchTargetPath(patchFile);
192
+ try {
193
+ const expectedSha256 = expectedByPath.get(path);
194
+ const currentText = expectedSha256 === "absent" || patchFile.isNewFile ? "" : await readProjectTextForPatch(workspace, path);
195
+ if (expectedSha256 !== undefined && expectedSha256 !== "absent") {
196
+ const safePath = await resolveSafeRemotePath(workspace.sftp, workspace.profile, workspace.rootPath, path, true);
197
+ const snapshot = await readRemoteFileSnapshot(workspace.sftp, safePath.remotePath, maxEditFileBytes(workspace.profile, runtimeConfig.get().defaultMaxEditFileBytes));
198
+ assertExpectedSha256(snapshot, expectedSha256, false, safePath.remotePath);
199
+ }
200
+ const nextText = applyUnifiedPatch(currentText, patchFile);
201
+ changedFiles.push({
202
+ path,
203
+ oldBytes: Buffer.byteLength(currentText, "utf8"),
204
+ newBytes: Buffer.byteLength(nextText, "utf8"),
205
+ newSha256: sha256(Buffer.from(nextText, "utf8")),
206
+ });
207
+ }
208
+ catch (error) {
209
+ conflicts.push({ path, error: error instanceof Error ? error.message : String(error) });
210
+ }
211
+ }
212
+ return {
213
+ applicable: conflicts.length === 0,
214
+ changedFiles,
215
+ conflicts,
216
+ };
217
+ }
218
+ async function applyProjectPatch(workspace, patchFiles, expectedFiles, createDirs) {
219
+ const expectedByPath = new Map(expectedFiles.map((file) => [file.path, file.sha256]));
220
+ const changes = [];
221
+ try {
222
+ for (const patchFile of patchFiles) {
223
+ const path = patchTargetPath(patchFile);
224
+ const expectedSha256 = expectedByPath.get(path);
225
+ if (expectedSha256 === undefined) {
226
+ throw new McpToolError("ssh_patch_expected_file_missing", "Patch file is missing expectedFiles entry", { path });
227
+ }
228
+ const currentText = expectedSha256 === "absent" || patchFile.isNewFile ? "" : await readProjectTextForPatch(workspace, path);
229
+ const nextText = applyUnifiedPatch(currentText, patchFile);
230
+ changes.push(await writeProjectFileChange(workspace, path, Buffer.from(nextText, "utf8"), expectedSha256, createDirs, undefined));
231
+ }
232
+ }
233
+ catch (error) {
234
+ await rollbackChangesBestEffort(workspace, changes);
235
+ throw error;
236
+ }
237
+ return recordEdit(workspace, changes);
238
+ }
239
+ async function writeProjectFileChange(workspace, path, data, expectedSha256, createDirs, mode) {
240
+ const maxBytes = maxEditFileBytes(workspace.profile, runtimeConfig.get().defaultMaxEditFileBytes);
241
+ if (data.length > maxBytes) {
242
+ throw new McpToolError("ssh_edit_file_too_large", "New file content exceeds max edit size", {
243
+ path,
244
+ bytes: data.length,
245
+ maxBytes,
246
+ });
247
+ }
248
+ if ((workspace.profile.allowBinaryEdit ?? false) !== true && data.includes(0)) {
249
+ throw new McpToolError("ssh_binary_edit_disabled", "Binary file editing is disabled for this profile", { path });
250
+ }
251
+ const safePath = await resolveSafeRemotePath(workspace.sftp, workspace.profile, workspace.rootPath, path, false);
252
+ const previous = await readOptionalSnapshot(workspace, safePath.remotePath);
253
+ assertExpectedSha256(previous, expectedSha256, true, safePath.remotePath);
254
+ const backup = previous !== undefined && (workspace.profile.backupBeforeEdit ?? true)
255
+ ? await createRemoteBackup(workspace.sftp, workspace.profile, workspace.rootPath, previous)
256
+ : undefined;
257
+ await writeRemoteFileAtomic(workspace.sftp, safePath.remotePath, data, mode ?? (previous === undefined ? undefined : previous.stats.mode & 0o777), createDirs);
258
+ const next = await readRemoteFileSnapshot(workspace.sftp, safePath.remotePath, maxBytes);
259
+ return compactChange({
260
+ path,
261
+ remotePath: safePath.remotePath,
262
+ operation: previous === undefined ? "create" : "modify",
263
+ oldSha256: previous?.sha256,
264
+ newSha256: next.sha256,
265
+ bytes: next.data.length,
266
+ backup,
267
+ });
268
+ }
269
+ async function deleteProjectFile(workspace, path, expectedSha256) {
270
+ if ((workspace.profile.allowDelete ?? false) !== true) {
271
+ throw new McpToolError("ssh_profile_delete_disabled", "SSH profile deletion is disabled", {
272
+ profileName: workspace.profile.name,
273
+ requiredEnv: `SSH_MCP_SERVER_<N>_ALLOW_DELETE=true`,
274
+ });
275
+ }
276
+ const safePath = await resolveSafeRemotePath(workspace.sftp, workspace.profile, workspace.rootPath, path, true);
277
+ const previous = await readRemoteFileSnapshot(workspace.sftp, safePath.remotePath, maxEditFileBytes(workspace.profile, runtimeConfig.get().defaultMaxEditFileBytes));
278
+ assertExpectedSha256(previous, expectedSha256, true, safePath.remotePath);
279
+ const backup = await createRemoteBackup(workspace.sftp, workspace.profile, workspace.rootPath, previous);
280
+ await sftpUnlink(workspace.sftp, safePath.remotePath);
281
+ return recordEdit(workspace, [
282
+ compactChange({
283
+ path,
284
+ remotePath: safePath.remotePath,
285
+ operation: "delete",
286
+ oldSha256: previous.sha256,
287
+ backup,
288
+ }),
289
+ ]);
290
+ }
291
+ async function moveProjectFile(workspace, fromPath, toPath, expectedSha256, createDirs) {
292
+ const from = await resolveSafeRemotePath(workspace.sftp, workspace.profile, workspace.rootPath, fromPath, true);
293
+ const to = await resolveSafeRemotePath(workspace.sftp, workspace.profile, workspace.rootPath, toPath, false);
294
+ const previous = await readRemoteFileSnapshot(workspace.sftp, from.remotePath, maxEditFileBytes(workspace.profile, runtimeConfig.get().defaultMaxEditFileBytes));
295
+ assertExpectedSha256(previous, expectedSha256, true, from.remotePath);
296
+ if (createDirs) {
297
+ await sftpMkdirRecursive(workspace.sftp, dirnameFromRemotePath(to.remotePath));
298
+ }
299
+ if (await remotePathExists(workspace, to.remotePath)) {
300
+ throw new McpToolError("ssh_move_target_exists", "Move target already exists", { toPath, remotePath: to.remotePath });
301
+ }
302
+ const backup = await createRemoteBackup(workspace.sftp, workspace.profile, workspace.rootPath, previous);
303
+ await sftpRename(workspace.sftp, from.remotePath, to.remotePath);
304
+ return recordEdit(workspace, [
305
+ compactChange({
306
+ path: fromPath,
307
+ remotePath: from.remotePath,
308
+ operation: "move",
309
+ oldSha256: previous.sha256,
310
+ newSha256: previous.sha256,
311
+ bytes: previous.data.length,
312
+ backup,
313
+ movedToPath: toPath,
314
+ movedToRemotePath: to.remotePath,
315
+ }),
316
+ ]);
317
+ }
318
+ async function rollbackEdit(workspace, edit) {
319
+ if (edit.profileName !== workspace.profile.name) {
320
+ throw new McpToolError("ssh_edit_profile_mismatch", "Edit record belongs to a different profile", {
321
+ editId: edit.editId,
322
+ expectedProfile: edit.profileName,
323
+ actualProfile: workspace.profile.name,
324
+ });
325
+ }
326
+ if (edit.rolledBackAt !== undefined) {
327
+ throw new McpToolError("ssh_edit_already_rolled_back", "Edit has already been rolled back", {
328
+ editId: edit.editId,
329
+ rolledBackAt: edit.rolledBackAt,
330
+ });
331
+ }
332
+ const rollbackChanges = [];
333
+ for (const change of [...edit.changes].reverse()) {
334
+ if (change.operation === "create") {
335
+ const current = await readOptionalSnapshot(workspace, change.remotePath);
336
+ if (current !== undefined) {
337
+ assertExpectedSha256(current, change.newSha256, false, change.remotePath);
338
+ await sftpUnlink(workspace.sftp, change.remotePath);
339
+ }
340
+ rollbackChanges.push({ ...change, operation: "delete", oldSha256: change.newSha256, newSha256: undefined });
341
+ continue;
342
+ }
343
+ if (change.operation === "move" && change.movedToRemotePath !== undefined) {
344
+ const moved = await readOptionalSnapshot(workspace, change.movedToRemotePath);
345
+ if (moved !== undefined) {
346
+ assertExpectedSha256(moved, change.newSha256, false, change.movedToRemotePath);
347
+ await sftpUnlink(workspace.sftp, change.movedToRemotePath);
348
+ }
349
+ }
350
+ if (change.backup === undefined) {
351
+ throw new McpToolError("ssh_edit_backup_missing", "Edit record has no backup for rollback", {
352
+ editId: edit.editId,
353
+ path: change.path,
354
+ });
355
+ }
356
+ const backup = await readRemoteFileSnapshot(workspace.sftp, change.backup.backupPath, maxEditFileBytes(workspace.profile, runtimeConfig.get().defaultMaxEditFileBytes));
357
+ await writeRemoteFileAtomic(workspace.sftp, change.remotePath, backup.data, undefined, true);
358
+ rollbackChanges.push({
359
+ path: change.path,
360
+ remotePath: change.remotePath,
361
+ operation: "modify",
362
+ oldSha256: change.newSha256,
363
+ newSha256: backup.sha256,
364
+ bytes: backup.data.length,
365
+ });
366
+ }
367
+ edit.rolledBackAt = new Date().toISOString();
368
+ return recordEdit(workspace, rollbackChanges);
369
+ }
370
+ async function readProjectTextForPatch(workspace, path) {
371
+ const safePath = await resolveSafeRemotePath(workspace.sftp, workspace.profile, workspace.rootPath, path, true);
372
+ const snapshot = await readRemoteFileSnapshot(workspace.sftp, safePath.remotePath, maxEditFileBytes(workspace.profile, runtimeConfig.get().defaultMaxEditFileBytes));
373
+ if ((workspace.profile.allowBinaryEdit ?? false) !== true && snapshot.data.includes(0)) {
374
+ throw new McpToolError("ssh_binary_edit_disabled", "Binary file editing is disabled for this profile", { path });
375
+ }
376
+ return snapshot.data.toString("utf8");
377
+ }
378
+ async function readOptionalSnapshot(workspace, remotePath) {
379
+ try {
380
+ return await readRemoteFileSnapshot(workspace.sftp, remotePath, maxEditFileBytes(workspace.profile, runtimeConfig.get().defaultMaxEditFileBytes));
381
+ }
382
+ catch (error) {
383
+ if (error instanceof McpToolError && /no such file|not found|failure/i.test(error.message)) {
384
+ return undefined;
385
+ }
386
+ throw error;
387
+ }
388
+ }
389
+ async function remotePathExists(workspace, remotePath) {
390
+ try {
391
+ await sftpStat(workspace.sftp, remotePath, false);
392
+ return true;
393
+ }
394
+ catch (error) {
395
+ if (error instanceof McpToolError && /no such file|not found|failure/i.test(error.message)) {
396
+ return false;
397
+ }
398
+ throw error;
399
+ }
400
+ }
401
+ async function resolveProjectPathArgs(workspace, paths, mustExist) {
402
+ const resolvedPaths = [];
403
+ for (const path of paths) {
404
+ const safePath = await resolveSafeRemotePath(workspace.sftp, workspace.profile, workspace.rootPath, path, mustExist);
405
+ resolvedPaths.push(relativeRemotePath(workspace.rootPath, safePath.remotePath));
406
+ }
407
+ return resolvedPaths;
408
+ }
409
+ async function rollbackChangesBestEffort(workspace, changes) {
410
+ for (const change of [...changes].reverse()) {
411
+ try {
412
+ if (change.operation === "create") {
413
+ await sftpUnlink(workspace.sftp, change.remotePath);
414
+ }
415
+ else if (change.backup !== undefined) {
416
+ const backup = await readRemoteFileSnapshot(workspace.sftp, change.backup.backupPath, maxEditFileBytes(workspace.profile, runtimeConfig.get().defaultMaxEditFileBytes));
417
+ await writeRemoteFileAtomic(workspace.sftp, change.remotePath, backup.data, undefined, true);
418
+ }
419
+ }
420
+ catch {
421
+ // Best-effort rollback keeps the original apply error as the actionable failure.
422
+ }
423
+ }
424
+ }
425
+ function recordEdit(workspace, changes) {
426
+ const record = {
427
+ editId: randomUUID(),
428
+ profileName: workspace.profile.name,
429
+ projectRoot: workspace.rootPath,
430
+ createdAt: new Date().toISOString(),
431
+ changes,
432
+ };
433
+ editHistory.set(record.editId, record);
434
+ return record;
435
+ }
436
+ function compactChange(change) {
437
+ return Object.fromEntries(Object.entries(change).filter(([, value]) => value !== undefined));
438
+ }
439
+ function assertPatchSize(patch) {
440
+ const bytes = Buffer.byteLength(patch, "utf8");
441
+ const maxBytes = runtimeConfig.get().defaultMaxPatchBytes;
442
+ if (bytes > maxBytes) {
443
+ throw new McpToolError("ssh_patch_too_large", "Unified patch exceeds max patch size", { bytes, maxBytes });
444
+ }
445
+ }
446
+ function compactTreeOptions(maxDepth, maxEntries, includeHidden, ignoreNames) {
447
+ return {
448
+ ...(maxDepth === undefined ? {} : { maxDepth }),
449
+ ...(maxEntries === undefined ? {} : { maxEntries }),
450
+ ...(includeHidden === undefined ? {} : { includeHidden }),
451
+ ...(ignoreNames === undefined ? {} : { ignoreNames }),
452
+ };
453
+ }
454
+ function buildProjectOpenCommand(rootPath) {
455
+ return buildRemoteCommand([
456
+ "printf 'pwd: '; pwd",
457
+ "printf 'git_root: '; git rev-parse --show-toplevel 2>/dev/null || true",
458
+ "printf 'git_branch: '; git branch --show-current 2>/dev/null || true",
459
+ "printf 'node_package: '; test -f package.json && echo yes || echo no",
460
+ "printf 'python_project: '; test -f pyproject.toml -o -f requirements.txt && echo yes || echo no",
461
+ "printf 'go_project: '; test -f go.mod && echo yes || echo no",
462
+ "printf 'rust_project: '; test -f Cargo.toml && echo yes || echo no",
463
+ ].join("; "), rootPath);
464
+ }
465
+ function buildProjectSearchCommand(rootPath, query, paths, literal, maxResults, contextLines) {
466
+ const rgLiteral = literal ? "--fixed-strings" : "";
467
+ const grepLiteral = literal ? "-F" : "-E";
468
+ const quotedQuery = quotePosixShellArg(query);
469
+ const quotedPaths = paths.length === 0 ? "." : paths.map(quotePosixShellArg).join(" ");
470
+ 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);
471
+ }
472
+ function buildProjectDiffCommand(rootPath, paths) {
473
+ const quotedPaths = paths.length === 0 ? "" : ` -- ${paths.map(quotePosixShellArg).join(" ")}`;
474
+ 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);
475
+ }
476
+ function parseSearchMatches(output) {
477
+ return output
478
+ .split(/\r?\n/)
479
+ .filter((line) => line.length > 0)
480
+ .map((line) => {
481
+ const match = /^(?<path>.*?):(?<line>\d+):(?<text>.*)$/.exec(line);
482
+ if (match?.groups === undefined) {
483
+ return { path: "", text: line };
484
+ }
485
+ return {
486
+ path: match.groups.path ?? "",
487
+ line: Number.parseInt(match.groups.line ?? "0", 10),
488
+ text: match.groups.text ?? "",
489
+ };
490
+ });
491
+ }
492
+ function dirnameFromRemotePath(remotePath) {
493
+ const parts = remotePath.split("/");
494
+ parts.pop();
495
+ const dirname = parts.join("/");
496
+ return dirname.length === 0 ? "." : dirname;
497
+ }
498
+ function relativeRemotePath(rootPath, remotePath) {
499
+ if (remotePath === rootPath) {
500
+ return ".";
501
+ }
502
+ if (remotePath.startsWith(`${rootPath}/`)) {
503
+ return remotePath.slice(rootPath.length + 1);
504
+ }
505
+ return remotePath;
506
+ }
507
+ function normalizeProfileNameForHistory(profileName) {
508
+ return profileName.trim().replace(/[\s-]+/g, "_").toUpperCase();
509
+ }
510
+ async function connectProfileSession(input) {
511
+ const profile = profiles.get(input.profileName);
512
+ const config = runtimeConfig.get();
513
+ const session = await sessions.connect({
514
+ host: profile.host,
515
+ port: profile.port,
516
+ username: profile.username,
517
+ name: input.sessionName ?? profile.name,
518
+ agentForward: input.agentForward ?? config.defaultAgentForward,
519
+ tryKeyboard: input.tryKeyboard ?? config.defaultTryKeyboard,
520
+ keyboardResponses: input.keyboardResponses,
521
+ keepaliveIntervalMs: input.keepaliveIntervalMs ?? config.defaultKeepaliveIntervalMs,
522
+ keepaliveCountMax: input.keepaliveCountMax ?? config.defaultKeepaliveCountMax,
523
+ readyTimeoutMs: input.readyTimeoutMs ?? config.defaultReadyTimeoutMs,
524
+ connectionTimeoutMs: input.connectionTimeoutMs ?? config.defaultConnectionTimeoutMs,
525
+ strictVendor: input.strictVendor,
526
+ debug: input.debug,
527
+ ...(profile.password === undefined ? {} : { password: profile.password }),
528
+ ...(profile.privateKey === undefined ? {} : { privateKey: profile.privateKey }),
529
+ ...(profile.privateKeyPath === undefined ? {} : { privateKeyPath: profile.privateKeyPath }),
530
+ ...(profile.passphrase === undefined ? {} : { passphrase: profile.passphrase }),
531
+ ...(profile.agent === undefined ? {} : { agent: profile.agent }),
532
+ ...(input.hostHash === undefined ? {} : { hostHash: input.hostHash }),
533
+ ...(input.expectedHostHash === undefined ? {} : { expectedHostHash: input.expectedHostHash }),
534
+ });
535
+ return {
536
+ profile: {
537
+ name: profile.name,
538
+ aliases: profile.aliases,
539
+ host: profile.host,
540
+ port: profile.port,
541
+ username: profile.username,
542
+ ...(profile.displayName === undefined ? {} : { displayName: profile.displayName }),
543
+ ...(profile.defaultDir === undefined ? {} : { defaultDir: profile.defaultDir }),
544
+ ...(profile.description === undefined ? {} : { description: profile.description }),
545
+ ...(profile.platform === undefined ? {} : { platform: profile.platform }),
546
+ },
547
+ session,
548
+ };
549
+ }
550
+ function buildRemoteCommand(command, workingDirectory) {
551
+ if (workingDirectory === undefined) {
552
+ return command;
553
+ }
554
+ return `cd ${quotePosixShellArg(workingDirectory)} && ${command}`;
555
+ }
556
+ function quotePosixShellArg(value) {
557
+ return `'${value.replaceAll("'", "'\\''")}'`;
558
+ }
559
+ async function runRemoteCheck(input) {
560
+ try {
561
+ const result = await input.execute(input.command);
562
+ return {
563
+ name: input.name,
564
+ title: input.title,
565
+ command: input.command,
566
+ ok: result.exitCode === 0 && !result.timedOut,
567
+ result,
568
+ };
569
+ }
570
+ catch (error) {
571
+ return {
572
+ name: input.name,
573
+ title: input.title,
574
+ command: input.command,
575
+ ok: false,
576
+ error: error instanceof Error ? error.message : String(error),
577
+ };
578
+ }
579
+ }
580
+ function compactExecPty(input) {
581
+ if (typeof input === "boolean" || input === undefined) {
582
+ return input;
583
+ }
584
+ return compactPty(input);
585
+ }
586
+ function compactPty(input) {
587
+ if (input === undefined) {
588
+ return undefined;
589
+ }
590
+ const pty = {};
591
+ if (input.rows !== undefined) {
592
+ pty.rows = input.rows;
593
+ }
594
+ if (input.cols !== undefined) {
595
+ pty.cols = input.cols;
596
+ }
597
+ if (input.height !== undefined) {
598
+ pty.height = input.height;
599
+ }
600
+ if (input.width !== undefined) {
601
+ pty.width = input.width;
602
+ }
603
+ if (input.term !== undefined) {
604
+ pty.term = input.term;
605
+ }
606
+ return pty;
607
+ }
608
+ export const toolContext = {
609
+ server,
610
+ envPath,
611
+ sessions,
612
+ z,
613
+ setCompactJsonResponse,
614
+ toToolResult,
615
+ profiles,
616
+ runtimeConfig,
617
+ reloadEnvState,
618
+ wireEncodingSchema,
619
+ remoteCheckNameSchema,
620
+ DEFAULT_REMOTE_CHECKS,
621
+ REMOTE_CHECK_COMMANDS,
622
+ registerTool,
623
+ connectProfileSession,
624
+ buildRemoteCommand,
625
+ runRemoteCheck,
626
+ sftpStat,
627
+ statsToJson,
628
+ McpToolError,
629
+ defaultTreeOptions,
630
+ maxEditFileBytes,
631
+ parseUnifiedPatch,
632
+ publicSnapshot,
633
+ readRemoteFileSnapshot,
634
+ readRemoteTree,
635
+ resolveSafeRemotePath,
636
+ sha256,
637
+ editHistory,
638
+ projectRootSchema,
639
+ fileEncodingSchema,
640
+ expectedShaSchema,
641
+ expectedFileSchema,
642
+ projectPathSchema,
643
+ connectProjectWorkspace,
644
+ inspectRemoteProject,
645
+ projectProfileSummary,
646
+ writeProjectFile,
647
+ applySinglePatchFile,
648
+ validateProjectPatch,
649
+ applyProjectPatch,
650
+ deleteProjectFile,
651
+ moveProjectFile,
652
+ rollbackEdit,
653
+ resolveProjectPathArgs,
654
+ assertPatchSize,
655
+ compactTreeOptions,
656
+ buildProjectSearchCommand,
657
+ buildProjectDiffCommand,
658
+ parseSearchMatches,
659
+ normalizeProfileNameForHistory,
660
+ sessionIdSchema,
661
+ ptySchema,
662
+ compactExecPty,
663
+ compactPty,
664
+ entryToJson,
665
+ sftpChmod,
666
+ sftpChown,
667
+ sftpMkdir,
668
+ sftpMkdirRecursive,
669
+ sftpReadFileLimited,
670
+ sftpReadDir,
671
+ sftpReadlink,
672
+ sftpRealpath,
673
+ sftpRemove,
674
+ sftpRename,
675
+ sftpSymlink,
676
+ sftpUtimes,
677
+ sftpWriteFile,
678
+ modeSchema,
679
+ assertNonEmpty,
680
+ transfers,
681
+ positivePortSchema
682
+ };
683
+ //# sourceMappingURL=tool-context.js.map