synapse-mcp 1.0.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.
Files changed (138) hide show
  1. package/README.md +607 -0
  2. package/dist/constants.d.ts +23 -0
  3. package/dist/constants.d.ts.map +1 -0
  4. package/dist/constants.js +58 -0
  5. package/dist/constants.js.map +1 -0
  6. package/dist/formatters/index.d.ts +275 -0
  7. package/dist/formatters/index.d.ts.map +1 -0
  8. package/dist/formatters/index.js +461 -0
  9. package/dist/formatters/index.js.map +1 -0
  10. package/dist/index.d.ts +3 -0
  11. package/dist/index.d.ts.map +1 -0
  12. package/dist/index.js +178 -0
  13. package/dist/index.js.map +1 -0
  14. package/dist/schemas/common.d.ts +48 -0
  15. package/dist/schemas/common.d.ts.map +1 -0
  16. package/dist/schemas/common.js +69 -0
  17. package/dist/schemas/common.js.map +1 -0
  18. package/dist/schemas/discriminator.d.ts +20 -0
  19. package/dist/schemas/discriminator.d.ts.map +1 -0
  20. package/dist/schemas/discriminator.js +25 -0
  21. package/dist/schemas/discriminator.js.map +1 -0
  22. package/dist/schemas/flux/compose.d.ts +93 -0
  23. package/dist/schemas/flux/compose.d.ts.map +1 -0
  24. package/dist/schemas/flux/compose.js +112 -0
  25. package/dist/schemas/flux/compose.js.map +1 -0
  26. package/dist/schemas/flux/container.d.ts +144 -0
  27. package/dist/schemas/flux/container.d.ts.map +1 -0
  28. package/dist/schemas/flux/container.js +163 -0
  29. package/dist/schemas/flux/container.js.map +1 -0
  30. package/dist/schemas/flux/docker.d.ts +91 -0
  31. package/dist/schemas/flux/docker.d.ts.map +1 -0
  32. package/dist/schemas/flux/docker.js +101 -0
  33. package/dist/schemas/flux/docker.js.map +1 -0
  34. package/dist/schemas/flux/host.d.ts +61 -0
  35. package/dist/schemas/flux/host.d.ts.map +1 -0
  36. package/dist/schemas/flux/host.js +72 -0
  37. package/dist/schemas/flux/host.js.map +1 -0
  38. package/dist/schemas/flux/index.d.ts +20 -0
  39. package/dist/schemas/flux/index.d.ts.map +1 -0
  40. package/dist/schemas/flux/index.js +88 -0
  41. package/dist/schemas/flux/index.js.map +1 -0
  42. package/dist/schemas/index.d.ts +11 -0
  43. package/dist/schemas/index.d.ts.map +1 -0
  44. package/dist/schemas/index.js +11 -0
  45. package/dist/schemas/index.js.map +1 -0
  46. package/dist/schemas/scout/index.d.ts +151 -0
  47. package/dist/schemas/scout/index.d.ts.map +1 -0
  48. package/dist/schemas/scout/index.js +41 -0
  49. package/dist/schemas/scout/index.js.map +1 -0
  50. package/dist/schemas/scout/logs.d.ts +48 -0
  51. package/dist/schemas/scout/logs.d.ts.map +1 -0
  52. package/dist/schemas/scout/logs.js +47 -0
  53. package/dist/schemas/scout/logs.js.map +1 -0
  54. package/dist/schemas/scout/simple.d.ts +68 -0
  55. package/dist/schemas/scout/simple.d.ts.map +1 -0
  56. package/dist/schemas/scout/simple.js +75 -0
  57. package/dist/schemas/scout/simple.js.map +1 -0
  58. package/dist/schemas/scout/zfs.d.ts +37 -0
  59. package/dist/schemas/scout/zfs.d.ts.map +1 -0
  60. package/dist/schemas/scout/zfs.js +36 -0
  61. package/dist/schemas/scout/zfs.js.map +1 -0
  62. package/dist/schemas/unified.d.ts +674 -0
  63. package/dist/schemas/unified.d.ts.map +1 -0
  64. package/dist/schemas/unified.js +453 -0
  65. package/dist/schemas/unified.js.map +1 -0
  66. package/dist/services/compose.d.ts +107 -0
  67. package/dist/services/compose.d.ts.map +1 -0
  68. package/dist/services/compose.js +308 -0
  69. package/dist/services/compose.js.map +1 -0
  70. package/dist/services/container.d.ts +69 -0
  71. package/dist/services/container.d.ts.map +1 -0
  72. package/dist/services/container.js +111 -0
  73. package/dist/services/container.js.map +1 -0
  74. package/dist/services/docker.d.ts +243 -0
  75. package/dist/services/docker.d.ts.map +1 -0
  76. package/dist/services/docker.js +812 -0
  77. package/dist/services/docker.js.map +1 -0
  78. package/dist/services/file-service.d.ts +79 -0
  79. package/dist/services/file-service.d.ts.map +1 -0
  80. package/dist/services/file-service.js +226 -0
  81. package/dist/services/file-service.js.map +1 -0
  82. package/dist/services/interfaces.d.ts +537 -0
  83. package/dist/services/interfaces.d.ts.map +1 -0
  84. package/dist/services/interfaces.js +2 -0
  85. package/dist/services/interfaces.js.map +1 -0
  86. package/dist/services/ssh-pool-exec.d.ts +10 -0
  87. package/dist/services/ssh-pool-exec.d.ts.map +1 -0
  88. package/dist/services/ssh-pool-exec.js +10 -0
  89. package/dist/services/ssh-pool-exec.js.map +1 -0
  90. package/dist/services/ssh-pool.d.ts +66 -0
  91. package/dist/services/ssh-pool.d.ts.map +1 -0
  92. package/dist/services/ssh-pool.js +253 -0
  93. package/dist/services/ssh-pool.js.map +1 -0
  94. package/dist/services/ssh-service.d.ts +39 -0
  95. package/dist/services/ssh-service.d.ts.map +1 -0
  96. package/dist/services/ssh-service.js +143 -0
  97. package/dist/services/ssh-service.js.map +1 -0
  98. package/dist/services/ssh.d.ts +37 -0
  99. package/dist/services/ssh.d.ts.map +1 -0
  100. package/dist/services/ssh.js +50 -0
  101. package/dist/services/ssh.js.map +1 -0
  102. package/dist/tools/flux.d.ts +14 -0
  103. package/dist/tools/flux.d.ts.map +1 -0
  104. package/dist/tools/flux.js +86 -0
  105. package/dist/tools/flux.js.map +1 -0
  106. package/dist/tools/index.d.ts +7 -0
  107. package/dist/tools/index.d.ts.map +1 -0
  108. package/dist/tools/index.js +43 -0
  109. package/dist/tools/index.js.map +1 -0
  110. package/dist/tools/scout.d.ts +14 -0
  111. package/dist/tools/scout.d.ts.map +1 -0
  112. package/dist/tools/scout.js +96 -0
  113. package/dist/tools/scout.js.map +1 -0
  114. package/dist/tools/unified.d.ts +7 -0
  115. package/dist/tools/unified.d.ts.map +1 -0
  116. package/dist/tools/unified.js +827 -0
  117. package/dist/tools/unified.js.map +1 -0
  118. package/dist/types.d.ts +93 -0
  119. package/dist/types.d.ts.map +1 -0
  120. package/dist/types.js +7 -0
  121. package/dist/types.js.map +1 -0
  122. package/dist/utils/errors.d.ts +60 -0
  123. package/dist/utils/errors.d.ts.map +1 -0
  124. package/dist/utils/errors.js +131 -0
  125. package/dist/utils/errors.js.map +1 -0
  126. package/dist/utils/help.d.ts +69 -0
  127. package/dist/utils/help.d.ts.map +1 -0
  128. package/dist/utils/help.js +259 -0
  129. package/dist/utils/help.js.map +1 -0
  130. package/dist/utils/index.d.ts +4 -0
  131. package/dist/utils/index.d.ts.map +1 -0
  132. package/dist/utils/index.js +4 -0
  133. package/dist/utils/index.js.map +1 -0
  134. package/dist/utils/path-security.d.ts +64 -0
  135. package/dist/utils/path-security.d.ts.map +1 -0
  136. package/dist/utils/path-security.js +138 -0
  137. package/dist/utils/path-security.js.map +1 -0
  138. package/package.json +85 -0
@@ -0,0 +1,827 @@
1
+ import { UnifiedHomelabSchema } from "../schemas/unified.js";
2
+ import { loadHostConfigs } from "../services/docker.js";
3
+ import { ResponseFormat } from "../types.js";
4
+ import { truncateIfNeeded, formatContainersMarkdown, formatLogsMarkdown, formatStatsMarkdown, formatMultiStatsMarkdown, formatInspectMarkdown, formatInspectSummaryMarkdown, formatHostStatusMarkdown, formatSearchResultsMarkdown, formatDockerInfoMarkdown, formatDockerDfMarkdown, formatPruneMarkdown, formatHostResourcesMarkdown, formatImagesMarkdown, formatComposeListMarkdown, formatComposeStatusMarkdown, formatScoutReadMarkdown, formatScoutListMarkdown, formatScoutTreeMarkdown, formatScoutExecMarkdown, formatScoutFindMarkdown, formatScoutTransferMarkdown, formatScoutDiffMarkdown } from "../formatters/index.js";
5
+ import { logError, HostOperationError } from "../utils/errors.js";
6
+ /**
7
+ * Collect container stats in parallel across hosts and containers
8
+ *
9
+ * Performance characteristics:
10
+ * - Hosts: Parallel execution via Promise.allSettled
11
+ * - Containers per host: Parallel execution via Promise.allSettled
12
+ * - Complexity: O(max(container_latency)) instead of O(hosts × containers)
13
+ * - Speedup: ~20x for 10 hosts × 20 containers (100s → 5s)
14
+ *
15
+ * Error handling:
16
+ * - Host failures: Logged to console.error, operation continues
17
+ * - Container failures: Skipped silently, partial results returned
18
+ * - Network timeouts: Handled by dockerode timeout config
19
+ *
20
+ * @param targetHosts - Hosts to collect stats from
21
+ * @param dockerService - Docker service instance for operations
22
+ * @param maxContainersPerHost - Maximum containers to query per host (default: 20)
23
+ * @returns Array of stats with host information (partial results on failures)
24
+ */
25
+ async function collectStatsParallel(targetHosts, dockerService, maxContainersPerHost = 20) {
26
+ // Parallel collection across hosts
27
+ const hostResults = await Promise.allSettled(targetHosts.map(async (host) => {
28
+ try {
29
+ // Get running containers for this host
30
+ const containers = await dockerService.listContainers([host], { state: "running" });
31
+ // Limit to maxContainersPerHost
32
+ const limitedContainers = containers.slice(0, maxContainersPerHost);
33
+ // Parallel collection across containers for this host
34
+ const containerResults = await Promise.allSettled(limitedContainers.map(async (container) => {
35
+ const stats = await dockerService.getContainerStats(container.id, host);
36
+ return { stats, host: host.name };
37
+ }));
38
+ // Filter successful container stat collections
39
+ return containerResults
40
+ .filter((result) => result.status === "fulfilled")
41
+ .map((result) => result.value);
42
+ }
43
+ catch (error) {
44
+ logError(new HostOperationError("Failed to collect stats from host", host.name, "collectStatsParallel", error), {
45
+ metadata: {
46
+ maxContainersPerHost,
47
+ timestamp: new Date().toISOString()
48
+ }
49
+ });
50
+ return [];
51
+ }
52
+ }));
53
+ // Flatten results from all hosts
54
+ const allStats = [];
55
+ for (const result of hostResults) {
56
+ if (result.status === "fulfilled") {
57
+ allStats.push(...result.value);
58
+ }
59
+ else {
60
+ console.error("Host stats collection failed:", result.reason);
61
+ }
62
+ }
63
+ return allStats;
64
+ }
65
+ /**
66
+ * Register the unified homelab tool
67
+ */
68
+ export function registerUnifiedTool(server, container) {
69
+ const hosts = loadHostConfigs();
70
+ const TOOL_DESCRIPTION = `Unified homelab Docker management tool.
71
+
72
+ ACTIONS:
73
+ container <subaction> - Container operations
74
+ list - List containers with filters
75
+ start/stop/restart - Control container state
76
+ pause/unpause - Pause/unpause container
77
+ logs - Get container logs
78
+ stats - Get resource usage stats
79
+ inspect - Get detailed container info
80
+ search - Search containers by query
81
+ pull - Pull latest image for container
82
+ recreate - Recreate container with latest image
83
+
84
+ compose <subaction> - Docker Compose operations
85
+ list - List compose projects
86
+ status - Get project status
87
+ up/down/restart - Control project state
88
+ logs - Get project logs
89
+ build - Build project images
90
+ pull - Pull project images
91
+ recreate - Force recreate containers
92
+
93
+ host <subaction> - Host operations
94
+ status - Check host connectivity
95
+ resources - Get CPU/memory/disk via SSH
96
+
97
+ docker <subaction> - Docker daemon operations (host parameter required)
98
+ info - Get Docker system info
99
+ df - Get disk usage
100
+ prune - Remove unused resources
101
+
102
+ image <subaction> - Image operations
103
+ list - List images
104
+ pull - Pull an image
105
+ build - Build from Dockerfile
106
+ remove - Remove an image
107
+
108
+ scout <subaction> - Remote file operations via SSH
109
+ read - Read file content
110
+ list - List directory contents
111
+ tree - Show directory tree
112
+ exec - Execute command
113
+ find - Find files by pattern
114
+ transfer - Transfer file between hosts
115
+ diff - Diff files across hosts
116
+
117
+ EXAMPLES:
118
+ { action: "container", subaction: "list", state: "running" }
119
+ { action: "container", subaction: "restart", container_id: "plex" }
120
+ { action: "compose", subaction: "up", host: "tootie", project: "plex" }
121
+ { action: "host", subaction: "resources", host: "tootie" }
122
+ { action: "docker", subaction: "info", host: "tootie" }
123
+ { action: "docker", subaction: "df", host: "tootie" }
124
+ { action: "docker", subaction: "prune", host: "tootie", prune_target: "images", force: true }
125
+ { action: "image", subaction: "pull", host: "tootie", image: "nginx:latest" }
126
+ { action: "scout", subaction: "read", host: "tootie", path: "/etc/hosts" }
127
+ { action: "scout", subaction: "list", host: "tootie", path: "/var/log" }
128
+ { action: "scout", subaction: "exec", host: "tootie", path: "/tmp", command: "ls -la" }`;
129
+ server.registerTool("homelab", {
130
+ title: "Homelab Manager",
131
+ description: TOOL_DESCRIPTION,
132
+ inputSchema: UnifiedHomelabSchema,
133
+ annotations: {
134
+ readOnlyHint: false,
135
+ destructiveHint: false,
136
+ idempotentHint: false,
137
+ openWorldHint: true
138
+ }
139
+ }, async (params) => {
140
+ try {
141
+ // Validate and parse input with Zod
142
+ const validated = UnifiedHomelabSchema.parse(params);
143
+ return await routeAction(validated, hosts, container);
144
+ }
145
+ catch (error) {
146
+ return {
147
+ isError: true,
148
+ content: [
149
+ {
150
+ type: "text",
151
+ text: `Error: ${error instanceof Error ? error.message : "Unknown error"}`
152
+ }
153
+ ]
154
+ };
155
+ }
156
+ });
157
+ }
158
+ /**
159
+ * Route action to appropriate handler
160
+ */
161
+ async function routeAction(params, hosts, container) {
162
+ const { action } = params;
163
+ switch (action) {
164
+ case "container":
165
+ return handleContainerAction(params, hosts, container);
166
+ case "compose":
167
+ return handleComposeAction(params, hosts, container);
168
+ case "host":
169
+ return handleHostAction(params, hosts, container);
170
+ case "docker":
171
+ return handleDockerAction(params, hosts, container);
172
+ case "image":
173
+ return handleImageAction(params, hosts, container);
174
+ case "scout":
175
+ return handleScoutAction(params, hosts, container);
176
+ default:
177
+ throw new Error(`Unknown action: ${action}`);
178
+ }
179
+ }
180
+ // ===== Container Action Handlers =====
181
+ async function handleContainerAction(params, hosts, container) {
182
+ if (params.action !== "container")
183
+ throw new Error("Invalid action");
184
+ const { subaction } = params;
185
+ const dockerService = container.getDockerService();
186
+ switch (subaction) {
187
+ case "list": {
188
+ const targetHosts = params.host ? hosts.filter((h) => h.name === params.host) : hosts;
189
+ if (params.host && targetHosts.length === 0) {
190
+ return errorResponse(`Host '${params.host}' not found. Available: ${hosts.map((h) => h.name).join(", ")}`);
191
+ }
192
+ const containers = await dockerService.listContainers(targetHosts, {
193
+ state: params.state,
194
+ nameFilter: params.name_filter,
195
+ imageFilter: params.image_filter,
196
+ labelFilter: params.label_filter
197
+ });
198
+ const total = containers.length;
199
+ const paginated = containers.slice(params.offset, params.offset + params.limit);
200
+ const hasMore = total > params.offset + params.limit;
201
+ const output = {
202
+ total,
203
+ count: paginated.length,
204
+ offset: params.offset,
205
+ containers: paginated,
206
+ has_more: hasMore
207
+ };
208
+ const text = params.response_format === ResponseFormat.JSON
209
+ ? JSON.stringify(output, null, 2)
210
+ : formatContainersMarkdown(paginated, total, params.offset, hasMore);
211
+ return successResponse(text, output);
212
+ }
213
+ case "start":
214
+ case "stop":
215
+ case "restart":
216
+ case "pause":
217
+ case "unpause": {
218
+ const targetHost = await resolveContainerHost(params.container_id, params.host, hosts, dockerService);
219
+ if (!targetHost) {
220
+ return errorResponse(`Container '${params.container_id}' not found.`);
221
+ }
222
+ await dockerService.containerAction(params.container_id, subaction, targetHost);
223
+ return successResponse(`✓ Successfully performed '${subaction}' on container '${params.container_id}' (host: ${targetHost.name})`);
224
+ }
225
+ case "logs": {
226
+ const targetHost = await resolveContainerHost(params.container_id, params.host, hosts, dockerService);
227
+ if (!targetHost) {
228
+ return errorResponse(`Container '${params.container_id}' not found.`);
229
+ }
230
+ let logs = await dockerService.getContainerLogs(params.container_id, targetHost, {
231
+ lines: params.lines,
232
+ since: params.since,
233
+ until: params.until,
234
+ stream: params.stream
235
+ });
236
+ if (params.grep) {
237
+ const grepLower = params.grep.toLowerCase();
238
+ logs = logs.filter((l) => l.message.toLowerCase().includes(grepLower));
239
+ }
240
+ const output = {
241
+ container: params.container_id,
242
+ host: targetHost.name,
243
+ count: logs.length,
244
+ logs
245
+ };
246
+ const text = params.response_format === ResponseFormat.JSON
247
+ ? JSON.stringify(output, null, 2)
248
+ : formatLogsMarkdown(logs, params.container_id, targetHost.name);
249
+ return successResponse(text, output);
250
+ }
251
+ case "stats": {
252
+ if (params.container_id) {
253
+ const targetHost = await resolveContainerHost(params.container_id, params.host, hosts, dockerService);
254
+ if (!targetHost) {
255
+ return errorResponse(`Container '${params.container_id}' not found.`);
256
+ }
257
+ const stats = await dockerService.getContainerStats(params.container_id, targetHost);
258
+ const output = { ...stats, host: targetHost.name };
259
+ const text = params.response_format === ResponseFormat.JSON
260
+ ? JSON.stringify(output, null, 2)
261
+ : formatStatsMarkdown([stats], targetHost.name);
262
+ return successResponse(text, output);
263
+ }
264
+ else {
265
+ const targetHosts = params.host ? hosts.filter((h) => h.name === params.host) : hosts;
266
+ // Collect stats in parallel across all hosts and containers
267
+ const allStats = await collectStatsParallel(targetHosts, dockerService, 20);
268
+ const output = { stats: allStats.map((s) => ({ ...s.stats, host: s.host })) };
269
+ const text = params.response_format === ResponseFormat.JSON
270
+ ? JSON.stringify(output, null, 2)
271
+ : formatMultiStatsMarkdown(allStats);
272
+ return successResponse(text, output);
273
+ }
274
+ }
275
+ case "inspect": {
276
+ const targetHost = await resolveContainerHost(params.container_id, params.host, hosts, dockerService);
277
+ if (!targetHost) {
278
+ return errorResponse(`Container '${params.container_id}' not found.`);
279
+ }
280
+ const info = await dockerService.inspectContainer(params.container_id, targetHost);
281
+ // Summary mode returns condensed output to save tokens
282
+ if (params.summary) {
283
+ const summary = {
284
+ id: info.Id?.slice(0, 12),
285
+ name: info.Name?.replace(/^\//, ""),
286
+ image: info.Config?.Image,
287
+ state: info.State?.Status,
288
+ created: info.Created,
289
+ started: info.State?.StartedAt,
290
+ restartCount: info.RestartCount,
291
+ ports: Object.keys(info.NetworkSettings?.Ports || {}).filter((p) => info.NetworkSettings?.Ports?.[p]),
292
+ mounts: (info.Mounts || []).map((m) => ({
293
+ src: m.Source,
294
+ dst: m.Destination,
295
+ type: m.Type
296
+ })),
297
+ networks: Object.keys(info.NetworkSettings?.Networks || {}),
298
+ env_count: (info.Config?.Env || []).length,
299
+ labels_count: Object.keys(info.Config?.Labels || {}).length,
300
+ host: targetHost.name
301
+ };
302
+ const text = params.response_format === ResponseFormat.JSON
303
+ ? JSON.stringify(summary, null, 2)
304
+ : formatInspectSummaryMarkdown(summary);
305
+ return successResponse(text, summary);
306
+ }
307
+ // Full mode returns complete inspect output
308
+ const output = { ...info, _host: targetHost.name };
309
+ const text = params.response_format === ResponseFormat.JSON
310
+ ? JSON.stringify(output, null, 2)
311
+ : formatInspectMarkdown(info, targetHost.name);
312
+ return successResponse(text, output);
313
+ }
314
+ case "search": {
315
+ const targetHosts = params.host ? hosts.filter((h) => h.name === params.host) : hosts;
316
+ const allContainers = await dockerService.listContainers(targetHosts, {});
317
+ const query = params.query.toLowerCase();
318
+ const matches = allContainers.filter((c) => {
319
+ const searchText = [c.name, c.image, ...Object.keys(c.labels), ...Object.values(c.labels)]
320
+ .join(" ")
321
+ .toLowerCase();
322
+ return searchText.includes(query);
323
+ });
324
+ const total = matches.length;
325
+ const paginated = matches.slice(params.offset, params.offset + params.limit);
326
+ const hasMore = total > params.offset + params.limit;
327
+ const output = {
328
+ query: params.query,
329
+ total,
330
+ count: paginated.length,
331
+ containers: paginated,
332
+ has_more: hasMore
333
+ };
334
+ const text = params.response_format === ResponseFormat.JSON
335
+ ? JSON.stringify(output, null, 2)
336
+ : formatSearchResultsMarkdown(paginated, params.query, total);
337
+ return successResponse(text, output);
338
+ }
339
+ case "pull": {
340
+ const targetHost = await resolveContainerHost(params.container_id, params.host, hosts, dockerService);
341
+ if (!targetHost) {
342
+ return errorResponse(`Container '${params.container_id}' not found.`);
343
+ }
344
+ const info = await dockerService.inspectContainer(params.container_id, targetHost);
345
+ const imageName = info.Config.Image;
346
+ await dockerService.pullImage(imageName, targetHost);
347
+ return successResponse(`✓ Successfully pulled latest image '${imageName}' for container '${params.container_id}'`);
348
+ }
349
+ case "recreate": {
350
+ const targetHost = await resolveContainerHost(params.container_id, params.host, hosts, dockerService);
351
+ if (!targetHost) {
352
+ return errorResponse(`Container '${params.container_id}' not found.`);
353
+ }
354
+ const result = await dockerService.recreateContainer(params.container_id, targetHost, {
355
+ pull: params.pull
356
+ });
357
+ return successResponse(`✓ ${result.status}. New container ID: ${result.containerId.slice(0, 12)}`);
358
+ }
359
+ default:
360
+ throw new Error(`Unknown container subaction: ${subaction}`);
361
+ }
362
+ }
363
+ // ===== Compose Action Handlers =====
364
+ async function handleComposeAction(params, hosts, container) {
365
+ if (params.action !== "compose")
366
+ throw new Error("Invalid action");
367
+ const { subaction } = params;
368
+ const composeService = container.getComposeService();
369
+ const targetHost = hosts.find((h) => h.name === params.host);
370
+ if (!targetHost) {
371
+ return errorResponse(`Host '${params.host}' not found.`);
372
+ }
373
+ switch (subaction) {
374
+ case "list": {
375
+ let projects = await composeService.listComposeProjects(targetHost);
376
+ // Apply name filter if provided
377
+ if (params.name_filter) {
378
+ const filter = params.name_filter.toLowerCase();
379
+ projects = projects.filter((p) => p.name.toLowerCase().includes(filter));
380
+ }
381
+ const total = projects.length;
382
+ const paginated = projects.slice(params.offset, params.offset + params.limit);
383
+ const hasMore = total > params.offset + params.limit;
384
+ const output = {
385
+ host: params.host,
386
+ total,
387
+ count: paginated.length,
388
+ offset: params.offset,
389
+ projects: paginated,
390
+ has_more: hasMore
391
+ };
392
+ const text = params.response_format === ResponseFormat.JSON
393
+ ? JSON.stringify(output, null, 2)
394
+ : formatComposeListMarkdown(paginated, params.host, total, params.offset, hasMore);
395
+ return successResponse(text, output);
396
+ }
397
+ case "status": {
398
+ let status = await composeService.getComposeStatus(targetHost, params.project);
399
+ // Apply service filter if provided
400
+ if (params.service_filter) {
401
+ const filter = params.service_filter.toLowerCase();
402
+ status = {
403
+ ...status,
404
+ services: status.services.filter((s) => s.name.toLowerCase().includes(filter))
405
+ };
406
+ }
407
+ const totalServices = status.services.length;
408
+ const paginatedServices = status.services.slice(params.offset, params.offset + params.limit);
409
+ const hasMore = totalServices > params.offset + params.limit;
410
+ const paginatedStatus = { ...status, services: paginatedServices };
411
+ const output = {
412
+ project: params.project,
413
+ host: params.host,
414
+ total_services: totalServices,
415
+ count: paginatedServices.length,
416
+ offset: params.offset,
417
+ has_more: hasMore,
418
+ status: paginatedStatus
419
+ };
420
+ const text = params.response_format === ResponseFormat.JSON
421
+ ? JSON.stringify(output, null, 2)
422
+ : formatComposeStatusMarkdown(paginatedStatus, totalServices, params.offset, hasMore);
423
+ return successResponse(text, output);
424
+ }
425
+ case "up": {
426
+ await composeService.composeUp(targetHost, params.project, params.detach);
427
+ const status = await composeService.getComposeStatus(targetHost, params.project);
428
+ const text = `✓ Started project '${params.project}'\n\n${formatComposeStatusMarkdown(status)}`;
429
+ return successResponse(text, { project: params.project, status });
430
+ }
431
+ case "down": {
432
+ await composeService.composeDown(targetHost, params.project, params.remove_volumes);
433
+ return successResponse(`✓ Stopped project '${params.project}'`);
434
+ }
435
+ case "restart": {
436
+ await composeService.composeRestart(targetHost, params.project);
437
+ const status = await composeService.getComposeStatus(targetHost, params.project);
438
+ const text = `✓ Restarted project '${params.project}'\n\n${formatComposeStatusMarkdown(status)}`;
439
+ return successResponse(text, { project: params.project, status });
440
+ }
441
+ case "logs": {
442
+ const logs = await composeService.composeLogs(targetHost, params.project, {
443
+ tail: params.lines,
444
+ services: params.service ? [params.service] : undefined
445
+ });
446
+ const title = params.service
447
+ ? `## Logs: ${params.project}/${params.service}`
448
+ : `## Logs: ${params.project}`;
449
+ const output = {
450
+ project: params.project,
451
+ host: params.host,
452
+ service: params.service || "all",
453
+ logs
454
+ };
455
+ const text = params.response_format === ResponseFormat.JSON
456
+ ? JSON.stringify(output, null, 2)
457
+ : `${title}\n\n\`\`\`\n${logs}\n\`\`\``;
458
+ return successResponse(text, output);
459
+ }
460
+ case "build": {
461
+ await composeService.composeBuild(targetHost, params.project, {
462
+ service: params.service,
463
+ noCache: params.no_cache
464
+ });
465
+ return successResponse(`✓ Built images for project '${params.project}'${params.service ? ` (service: ${params.service})` : ""}`);
466
+ }
467
+ case "pull": {
468
+ await composeService.composePull(targetHost, params.project, { service: params.service });
469
+ return successResponse(`✓ Pulled images for project '${params.project}'${params.service ? ` (service: ${params.service})` : ""}`);
470
+ }
471
+ case "recreate": {
472
+ await composeService.composeRecreate(targetHost, params.project, { service: params.service });
473
+ const status = await composeService.getComposeStatus(targetHost, params.project);
474
+ const text = `✓ Recreated project '${params.project}'${params.service ? ` (service: ${params.service})` : ""}\n\n${formatComposeStatusMarkdown(status)}`;
475
+ return successResponse(text, { project: params.project, status });
476
+ }
477
+ default:
478
+ throw new Error(`Unknown compose subaction: ${subaction}`);
479
+ }
480
+ }
481
+ // ===== Host Action Handlers =====
482
+ async function handleHostAction(params, hosts, container) {
483
+ if (params.action !== "host")
484
+ throw new Error("Invalid action");
485
+ const { subaction } = params;
486
+ const dockerService = container.getDockerService();
487
+ const sshService = container.getSSHService();
488
+ switch (subaction) {
489
+ case "status": {
490
+ const targetHosts = params.host ? hosts.filter((h) => h.name === params.host) : hosts;
491
+ if (params.host && targetHosts.length === 0) {
492
+ return errorResponse(`Host '${params.host}' not found.`);
493
+ }
494
+ const status = await dockerService.getHostStatus(targetHosts);
495
+ const output = { hosts: status };
496
+ const text = params.response_format === ResponseFormat.JSON
497
+ ? JSON.stringify(output, null, 2)
498
+ : formatHostStatusMarkdown(status);
499
+ return successResponse(text, output);
500
+ }
501
+ case "resources": {
502
+ const targetHosts = params.host ? hosts.filter((h) => h.name === params.host) : hosts;
503
+ if (params.host && targetHosts.length === 0) {
504
+ return errorResponse(`Host '${params.host}' not found.`);
505
+ }
506
+ const results = await Promise.all(targetHosts.map(async (host) => {
507
+ if (host.host.startsWith("/")) {
508
+ return { host: host.name, resources: null, error: "Local socket - SSH not available" };
509
+ }
510
+ try {
511
+ const resources = await sshService.getHostResources(host);
512
+ return { host: host.name, resources };
513
+ }
514
+ catch (error) {
515
+ logError(new HostOperationError("Failed to get host resources", host.name, "getHostResources", error), { operation: "handleHostAction:resources" });
516
+ return {
517
+ host: host.name,
518
+ resources: null,
519
+ error: error instanceof Error ? error.message : "SSH failed"
520
+ };
521
+ }
522
+ }));
523
+ const output = { hosts: results };
524
+ const text = params.response_format === ResponseFormat.JSON
525
+ ? JSON.stringify(output, null, 2)
526
+ : formatHostResourcesMarkdown(results);
527
+ return successResponse(text, output);
528
+ }
529
+ default:
530
+ throw new Error(`Unknown host subaction: ${subaction}`);
531
+ }
532
+ }
533
+ // ===== Docker Action Handlers =====
534
+ async function handleDockerAction(params, hosts, container) {
535
+ if (params.action !== "docker")
536
+ throw new Error("Invalid action");
537
+ const { subaction } = params;
538
+ const dockerService = container.getDockerService();
539
+ switch (subaction) {
540
+ case "info": {
541
+ const targetHost = hosts.find((h) => h.name === params.host);
542
+ if (!targetHost) {
543
+ return errorResponse(`Host '${params.host}' not found.`);
544
+ }
545
+ try {
546
+ const info = await dockerService.getDockerInfo(targetHost);
547
+ const results = [{ host: targetHost.name, info }];
548
+ const output = { hosts: results };
549
+ const text = params.response_format === ResponseFormat.JSON
550
+ ? JSON.stringify(output, null, 2)
551
+ : formatDockerInfoMarkdown(results);
552
+ return successResponse(text, output);
553
+ }
554
+ catch (error) {
555
+ return errorResponse(`Failed to get Docker info from ${targetHost.name}: ${error instanceof Error ? error.message : "Connection failed"}`);
556
+ }
557
+ }
558
+ case "df": {
559
+ const targetHost = hosts.find((h) => h.name === params.host);
560
+ if (!targetHost) {
561
+ return errorResponse(`Host '${params.host}' not found.`);
562
+ }
563
+ try {
564
+ const usage = await dockerService.getDockerDiskUsage(targetHost);
565
+ const results = [{ host: targetHost.name, usage }];
566
+ const output = { hosts: results };
567
+ const text = params.response_format === ResponseFormat.JSON
568
+ ? JSON.stringify(output, null, 2)
569
+ : formatDockerDfMarkdown(results);
570
+ return successResponse(text, output);
571
+ }
572
+ catch (error) {
573
+ return errorResponse(`Failed to get disk usage from ${targetHost.name}: ${error instanceof Error ? error.message : "Connection failed"}`);
574
+ }
575
+ }
576
+ case "prune": {
577
+ if (!params.force) {
578
+ return errorResponse("⚠️ This is a destructive operation. Set force=true to confirm.");
579
+ }
580
+ const targetHost = hosts.find((h) => h.name === params.host);
581
+ if (!targetHost) {
582
+ return errorResponse(`Host '${params.host}' not found.`);
583
+ }
584
+ try {
585
+ const results = await dockerService.pruneDocker(targetHost, params.prune_target);
586
+ const allResults = [{ host: targetHost.name, results }];
587
+ const output = { hosts: allResults };
588
+ const text = formatPruneMarkdown(allResults);
589
+ return successResponse(text, output);
590
+ }
591
+ catch (error) {
592
+ return errorResponse(`Failed to prune on ${targetHost.name}: ${error instanceof Error ? error.message : "Connection failed"}`);
593
+ }
594
+ }
595
+ default:
596
+ throw new Error(`Unknown docker subaction: ${subaction}`);
597
+ }
598
+ }
599
+ // ===== Image Action Handlers =====
600
+ async function handleImageAction(params, hosts, container) {
601
+ if (params.action !== "image")
602
+ throw new Error("Invalid action");
603
+ const { subaction } = params;
604
+ const dockerService = container.getDockerService();
605
+ switch (subaction) {
606
+ case "list": {
607
+ const targetHosts = params.host ? hosts.filter((h) => h.name === params.host) : hosts;
608
+ if (params.host && targetHosts.length === 0) {
609
+ return errorResponse(`Host '${params.host}' not found.`);
610
+ }
611
+ const images = await dockerService.listImages(targetHosts, {
612
+ danglingOnly: params.dangling_only
613
+ });
614
+ const paginated = images.slice(params.offset, params.offset + params.limit);
615
+ const output = {
616
+ images: paginated,
617
+ pagination: {
618
+ total: images.length,
619
+ count: paginated.length,
620
+ offset: params.offset,
621
+ hasMore: params.offset + params.limit < images.length
622
+ }
623
+ };
624
+ const text = params.response_format === ResponseFormat.JSON
625
+ ? JSON.stringify(output, null, 2)
626
+ : formatImagesMarkdown(paginated, images.length, params.offset);
627
+ return successResponse(text, output);
628
+ }
629
+ case "pull": {
630
+ const targetHost = hosts.find((h) => h.name === params.host);
631
+ if (!targetHost) {
632
+ return errorResponse(`Host '${params.host}' not found.`);
633
+ }
634
+ await dockerService.pullImage(params.image, targetHost);
635
+ return successResponse(`✓ Successfully pulled image '${params.image}' on ${params.host}`);
636
+ }
637
+ case "build": {
638
+ const targetHost = hosts.find((h) => h.name === params.host);
639
+ if (!targetHost) {
640
+ return errorResponse(`Host '${params.host}' not found.`);
641
+ }
642
+ await dockerService.buildImage(targetHost, {
643
+ context: params.context,
644
+ tag: params.tag,
645
+ dockerfile: params.dockerfile,
646
+ noCache: params.no_cache
647
+ });
648
+ return successResponse(`✓ Successfully built image '${params.tag}' on ${params.host}`);
649
+ }
650
+ case "remove": {
651
+ const targetHost = hosts.find((h) => h.name === params.host);
652
+ if (!targetHost) {
653
+ return errorResponse(`Host '${params.host}' not found.`);
654
+ }
655
+ await dockerService.removeImage(params.image, targetHost, { force: params.force });
656
+ return successResponse(`✓ Successfully removed image '${params.image}' from ${params.host}`);
657
+ }
658
+ default:
659
+ throw new Error(`Unknown image subaction: ${subaction}`);
660
+ }
661
+ }
662
+ // ===== Scout Action Handlers =====
663
+ async function handleScoutAction(params, hosts, container) {
664
+ if (params.action !== "scout")
665
+ throw new Error("Invalid action");
666
+ const { subaction } = params;
667
+ const fileService = container.getFileService();
668
+ switch (subaction) {
669
+ case "read": {
670
+ const targetHost = hosts.find((h) => h.name === params.host);
671
+ if (!targetHost) {
672
+ return errorResponse(`Host '${params.host}' not found.`);
673
+ }
674
+ const result = await fileService.readFile(targetHost, params.path, params.max_size);
675
+ const output = {
676
+ host: params.host,
677
+ path: params.path,
678
+ content: result.content,
679
+ size: result.size,
680
+ truncated: result.truncated
681
+ };
682
+ const text = params.response_format === ResponseFormat.JSON
683
+ ? JSON.stringify(output, null, 2)
684
+ : formatScoutReadMarkdown(params.host, params.path, result.content, result.size, result.truncated);
685
+ return successResponse(text, output);
686
+ }
687
+ case "list": {
688
+ const targetHost = hosts.find((h) => h.name === params.host);
689
+ if (!targetHost) {
690
+ return errorResponse(`Host '${params.host}' not found.`);
691
+ }
692
+ const listing = await fileService.listDirectory(targetHost, params.path, params.all);
693
+ const output = {
694
+ host: params.host,
695
+ path: params.path,
696
+ listing
697
+ };
698
+ const text = params.response_format === ResponseFormat.JSON
699
+ ? JSON.stringify(output, null, 2)
700
+ : formatScoutListMarkdown(params.host, params.path, listing);
701
+ return successResponse(text, output);
702
+ }
703
+ case "tree": {
704
+ const targetHost = hosts.find((h) => h.name === params.host);
705
+ if (!targetHost) {
706
+ return errorResponse(`Host '${params.host}' not found.`);
707
+ }
708
+ const tree = await fileService.treeDirectory(targetHost, params.path, params.depth);
709
+ const output = {
710
+ host: params.host,
711
+ path: params.path,
712
+ depth: params.depth,
713
+ tree
714
+ };
715
+ const text = params.response_format === ResponseFormat.JSON
716
+ ? JSON.stringify(output, null, 2)
717
+ : formatScoutTreeMarkdown(params.host, params.path, tree, params.depth);
718
+ return successResponse(text, output);
719
+ }
720
+ case "exec": {
721
+ const targetHost = hosts.find((h) => h.name === params.host);
722
+ if (!targetHost) {
723
+ return errorResponse(`Host '${params.host}' not found.`);
724
+ }
725
+ const result = await fileService.executeCommand(targetHost, params.path, params.command, params.timeout);
726
+ const output = {
727
+ host: params.host,
728
+ path: params.path,
729
+ command: params.command,
730
+ stdout: result.stdout,
731
+ exitCode: result.exitCode
732
+ };
733
+ const text = params.response_format === ResponseFormat.JSON
734
+ ? JSON.stringify(output, null, 2)
735
+ : formatScoutExecMarkdown(params.host, params.path, params.command, result.stdout, result.exitCode);
736
+ return successResponse(text, output);
737
+ }
738
+ case "find": {
739
+ const targetHost = hosts.find((h) => h.name === params.host);
740
+ if (!targetHost) {
741
+ return errorResponse(`Host '${params.host}' not found.`);
742
+ }
743
+ const results = await fileService.findFiles(targetHost, params.path, params.pattern, {
744
+ type: params.type,
745
+ maxDepth: params.max_depth,
746
+ limit: params.limit
747
+ });
748
+ const output = {
749
+ host: params.host,
750
+ path: params.path,
751
+ pattern: params.pattern,
752
+ results
753
+ };
754
+ const text = params.response_format === ResponseFormat.JSON
755
+ ? JSON.stringify(output, null, 2)
756
+ : formatScoutFindMarkdown(params.host, params.path, params.pattern, results);
757
+ return successResponse(text, output);
758
+ }
759
+ case "transfer": {
760
+ const sourceHost = hosts.find((h) => h.name === params.source_host);
761
+ const targetHost = hosts.find((h) => h.name === params.target_host);
762
+ if (!sourceHost) {
763
+ return errorResponse(`Source host '${params.source_host}' not found.`);
764
+ }
765
+ if (!targetHost) {
766
+ return errorResponse(`Target host '${params.target_host}' not found.`);
767
+ }
768
+ const result = await fileService.transferFile(sourceHost, params.source_path, targetHost, params.target_path);
769
+ const output = {
770
+ source_host: params.source_host,
771
+ source_path: params.source_path,
772
+ target_host: params.target_host,
773
+ target_path: params.target_path,
774
+ bytes_transferred: result.bytesTransferred,
775
+ warning: result.warning
776
+ };
777
+ const text = formatScoutTransferMarkdown(params.source_host, params.source_path, params.target_host, params.target_path, result.bytesTransferred, result.warning);
778
+ return successResponse(text, output);
779
+ }
780
+ case "diff": {
781
+ const host1 = hosts.find((h) => h.name === params.host1);
782
+ const host2 = hosts.find((h) => h.name === params.host2);
783
+ if (!host1) {
784
+ return errorResponse(`Host '${params.host1}' not found.`);
785
+ }
786
+ if (!host2) {
787
+ return errorResponse(`Host '${params.host2}' not found.`);
788
+ }
789
+ const diff = await fileService.diffFiles(host1, params.path1, host2, params.path2, params.context_lines);
790
+ const output = {
791
+ host1: params.host1,
792
+ path1: params.path1,
793
+ host2: params.host2,
794
+ path2: params.path2,
795
+ diff
796
+ };
797
+ const text = params.response_format === ResponseFormat.JSON
798
+ ? JSON.stringify(output, null, 2)
799
+ : formatScoutDiffMarkdown(params.host1, params.path1, params.host2, params.path2, diff);
800
+ return successResponse(text, output);
801
+ }
802
+ default:
803
+ throw new Error(`Unknown scout subaction: ${subaction}`);
804
+ }
805
+ }
806
+ // ===== Helper Functions =====
807
+ async function resolveContainerHost(containerId, hostName, hosts, dockerService) {
808
+ if (hostName) {
809
+ const found = hosts.find((h) => h.name === hostName);
810
+ return found || null;
811
+ }
812
+ const result = await dockerService.findContainerHost(containerId, hosts);
813
+ return result?.host || null;
814
+ }
815
+ function successResponse(text, structuredContent) {
816
+ return {
817
+ content: [{ type: "text", text: truncateIfNeeded(text) }],
818
+ ...(structuredContent ? { structuredContent } : {})
819
+ };
820
+ }
821
+ function errorResponse(message) {
822
+ return {
823
+ isError: true,
824
+ content: [{ type: "text", text: message }]
825
+ };
826
+ }
827
+ //# sourceMappingURL=unified.js.map