@supernova123/docker-mcp-server 0.3.3 → 0.3.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,362 @@
1
+ import { DurableObject } from "cloudflare:workers";
2
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
+ import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
4
+ import { z } from "zod";
5
+ import { TIER_LIMITS, type UserState, type Env } from "./types.js";
6
+
7
+ /**
8
+ * McpAgentDO — Durable Object that hosts a per-user MCP server.
9
+ *
10
+ * Each user gets their own DO instance (deterministic ID from userId).
11
+ * The DO holds per-user state (rate limits, tier) and proxies tool
12
+ * calls to the user's Docker daemon via Cloudflare Tunnel.
13
+ *
14
+ * Architecture:
15
+ * Routing Worker (auth, CORS) → McpAgentDO (tool dispatch) → User's Docker daemon
16
+ */
17
+ export class McpAgentDO extends DurableObject {
18
+ private state: DurableObjectState;
19
+ private userState: UserState | null = null;
20
+
21
+ constructor(state: DurableObjectState, env: Env) {
22
+ super(state, env);
23
+ this.state = state;
24
+ }
25
+
26
+ async initialize(userId: string, tier: string): Promise<void> {
27
+ const existing = await this.state.storage.get<UserState>("userState");
28
+ if (!existing) {
29
+ const newUser: UserState = {
30
+ userId,
31
+ tier: tier as UserState["tier"],
32
+ toolCallsToday: 0,
33
+ lastReset: new Date().toISOString().split("T")[0],
34
+ maxToolCallsPerDay: TIER_LIMITS[tier] || TIER_LIMITS.free,
35
+ };
36
+ await this.state.storage.put("userState", newUser);
37
+ this.userState = newUser;
38
+ } else {
39
+ this.userState = existing;
40
+ // Reset daily counter if needed
41
+ const today = new Date().toISOString().split("T")[0];
42
+ if (existing.lastReset !== today) {
43
+ existing.toolCallsToday = 0;
44
+ existing.lastReset = today;
45
+ await this.state.storage.put("userState", existing);
46
+ }
47
+ }
48
+ }
49
+
50
+
51
+ async setTunnelUrl(url: string): Promise<{ ok: boolean; tunnelUrl: string }> {
52
+ // Validate URL format
53
+ try {
54
+ new URL(url);
55
+ } catch {
56
+ return { ok: false, tunnelUrl: "" };
57
+ }
58
+ await this.state.storage.put("tunnelUrl", url);
59
+ return { ok: true, tunnelUrl: url };
60
+ }
61
+
62
+ async getTunnelUrl(): Promise<string> {
63
+ return (await this.state.storage.get<string>("tunnelUrl")) || "";
64
+ }
65
+
66
+ async handleMcpRequest(request: Request): Promise<Response> {
67
+ if (!this.userState) {
68
+ return new Response("User not initialized", { status: 500 });
69
+ }
70
+
71
+ // Check rate limit
72
+ if (this.userState.toolCallsToday >= this.userState.maxToolCallsPerDay) {
73
+ return new Response(
74
+ JSON.stringify({
75
+ jsonrpc: "2.0",
76
+ error: {
77
+ code: -32000,
78
+ message: `Rate limit exceeded: ${this.userState.maxToolCallsPerDay} calls/day for ${this.userState.tier} tier`,
79
+ },
80
+ id: null,
81
+ }),
82
+ { status: 429, headers: { "Content-Type": "application/json" } }
83
+ );
84
+ }
85
+
86
+ // Create MCP server for this request
87
+ const server = this.createServer();
88
+ const transport = new WebStandardStreamableHTTPServerTransport();
89
+
90
+ await server.connect(transport);
91
+ const response = await transport.handleRequest(request);
92
+
93
+ // Increment counter on successful tool call
94
+ this.userState.toolCallsToday++;
95
+ await this.state.storage.put("userState", this.userState);
96
+
97
+ return response;
98
+ }
99
+
100
+ private createServer(): McpServer {
101
+ const server = new McpServer({
102
+ name: "docker-mcp-hosted",
103
+ version: "0.4.0",
104
+ });
105
+
106
+ // ── Container Management ──────────────────────────────────
107
+ server.registerTool(
108
+ "list_containers",
109
+ { description: "List Docker containers with optional filters" },
110
+ async () => this.proxyToDocker("list_containers", {})
111
+ );
112
+
113
+ server.registerTool(
114
+ "inspect_container",
115
+ {
116
+ description: "Get detailed info about a Docker container",
117
+ inputSchema: { name: z.string() },
118
+ },
119
+ async ({ name }) => this.proxyToDocker("inspect_container", { name })
120
+ );
121
+
122
+ server.registerTool(
123
+ "start_container",
124
+ {
125
+ description: "Start a stopped Docker container",
126
+ inputSchema: { name: z.string() },
127
+ },
128
+ async ({ name }) => this.proxyToDocker("start_container", { name })
129
+ );
130
+
131
+ server.registerTool(
132
+ "stop_container",
133
+ {
134
+ description: "Stop a running Docker container",
135
+ inputSchema: { name: z.string(), timeout: z.number().optional() },
136
+ },
137
+ async ({ name, timeout }) =>
138
+ this.proxyToDocker("stop_container", { name, timeout })
139
+ );
140
+
141
+ server.registerTool(
142
+ "restart_container",
143
+ {
144
+ description: "Restart a Docker container",
145
+ inputSchema: { name: z.string() },
146
+ },
147
+ async ({ name }) => this.proxyToDocker("restart_container", { name })
148
+ );
149
+
150
+ server.registerTool(
151
+ "remove_container",
152
+ {
153
+ description: "Remove a Docker container",
154
+ inputSchema: { name: z.string(), force: z.boolean().optional() },
155
+ },
156
+ async ({ name, force }) =>
157
+ this.proxyToDocker("remove_container", { name, force })
158
+ );
159
+
160
+ server.registerTool(
161
+ "create_container",
162
+ {
163
+ description: "Create a new Docker container from an image",
164
+ inputSchema: {
165
+ image: z.string(),
166
+ name: z.string().optional(),
167
+ ports: z.record(z.string()).optional(),
168
+ env: z.record(z.string()).optional(),
169
+ volumes: z.array(z.string()).optional(),
170
+ },
171
+ },
172
+ async (params) => this.proxyToDocker("create_container", params)
173
+ );
174
+
175
+ // ── Compose Lifecycle ─────────────────────────────────────
176
+ server.registerTool(
177
+ "compose_up",
178
+ {
179
+ description: "Start services defined in a docker-compose.yml file",
180
+ inputSchema: {
181
+ project: z.string(),
182
+ path: z.string().optional(),
183
+ detach: z.boolean().optional(),
184
+ },
185
+ },
186
+ async (params) => this.proxyToDocker("compose_up", params)
187
+ );
188
+
189
+ server.registerTool(
190
+ "compose_down",
191
+ {
192
+ description: "Stop and remove services defined in a docker-compose.yml file",
193
+ inputSchema: { project: z.string(), path: z.string().optional() },
194
+ },
195
+ async (params) => this.proxyToDocker("compose_down", params)
196
+ );
197
+
198
+ server.registerTool(
199
+ "compose_ps",
200
+ {
201
+ description: "List services in a Compose project",
202
+ inputSchema: { project: z.string() },
203
+ },
204
+ async ({ project }) => this.proxyToDocker("compose_ps", { project })
205
+ );
206
+
207
+ server.registerTool(
208
+ "compose_logs",
209
+ {
210
+ description: "Get logs from Compose services",
211
+ inputSchema: {
212
+ project: z.string(),
213
+ service: z.string().optional(),
214
+ tail: z.number().optional(),
215
+ },
216
+ },
217
+ async (params) => this.proxyToDocker("compose_logs", params)
218
+ );
219
+
220
+ // ── Monitoring ────────────────────────────────────────────
221
+ server.registerTool(
222
+ "fleet_status",
223
+ {
224
+ description: "Get status overview of all Docker containers",
225
+ },
226
+ async () => this.proxyToDocker("fleet_status", {})
227
+ );
228
+
229
+ server.registerTool(
230
+ "search_logs",
231
+ {
232
+ description: "Search container logs by keyword",
233
+ inputSchema: {
234
+ name: z.string(),
235
+ query: z.string(),
236
+ tail: z.number().optional(),
237
+ },
238
+ },
239
+ async (params) => this.proxyToDocker("search_logs", params)
240
+ );
241
+
242
+ server.registerTool(
243
+ "watch_events",
244
+ {
245
+ description: "Stream Docker daemon events (containers, images, networks)",
246
+ inputSchema: { since: z.string().optional() },
247
+ },
248
+ async (params) => this.proxyToDocker("watch_events", params)
249
+ );
250
+
251
+ // ── Image Management ──────────────────────────────────────
252
+ server.registerTool(
253
+ "list_images",
254
+ { description: "List Docker images on the host" },
255
+ async () => this.proxyToDocker("list_images", {})
256
+ );
257
+
258
+ server.registerTool(
259
+ "pull_image",
260
+ {
261
+ description: "Pull a Docker image from a registry",
262
+ inputSchema: { image: z.string() },
263
+ },
264
+ async ({ image }) => this.proxyToDocker("pull_image", { image })
265
+ );
266
+
267
+ // ── Network & Volume ──────────────────────────────────────
268
+ server.registerTool(
269
+ "list_networks",
270
+ { description: "List Docker networks" },
271
+ async () => this.proxyToDocker("list_networks", {})
272
+ );
273
+
274
+ server.registerTool(
275
+ "list_volumes",
276
+ { description: "List Docker volumes" },
277
+ async () => this.proxyToDocker("list_volumes", {})
278
+ );
279
+
280
+ return server;
281
+ }
282
+
283
+ /**
284
+ * Proxy a tool call to the user's Docker daemon via Cloudflare Tunnel.
285
+ *
286
+ * Architecture: The user runs `docker-mcp-server` locally, exposed via
287
+ * `cloudflared tunnel`. This DO forwards the tool call request to that
288
+ * endpoint. The tunnel URL is stored per-user in KV or passed at init.
289
+ *
290
+ * For now, returns a placeholder response. Real implementation connects
291
+ * to the user's tunnel endpoint.
292
+ */
293
+ private async proxyToDocker(
294
+ tool: string,
295
+ args: Record<string, unknown>
296
+ ): Promise<{ content: Array<{ type: "text"; text: string }> }> {
297
+ // TODO: Connect to user's Cloudflare Tunnel endpoint
298
+ // The tunnel URL should be stored in KV or passed during user setup.
299
+ // For now, return a structured placeholder.
300
+ const tunnelUrl = await this.state.storage.get<string>("tunnelUrl");
301
+
302
+ if (!tunnelUrl) {
303
+ return {
304
+ content: [
305
+ {
306
+ type: "text",
307
+ text: JSON.stringify({
308
+ error: "No Docker tunnel configured. Please set up a Cloudflare Tunnel to connect your Docker daemon.",
309
+ tool,
310
+ args,
311
+ }),
312
+ },
313
+ ],
314
+ };
315
+ }
316
+
317
+ // Forward the tool call to the user's tunnel
318
+ try {
319
+ const response = await fetch(tunnelUrl, {
320
+ method: "POST",
321
+ headers: { "Content-Type": "application/json" },
322
+ body: JSON.stringify({
323
+ jsonrpc: "2.0",
324
+ method: "tools/call",
325
+ params: { name: tool, arguments: args },
326
+ id: crypto.randomUUID(),
327
+ }),
328
+ });
329
+
330
+ const result = (await response.json()) as {
331
+ result?: { content: Array<{ type: string; text: string }> };
332
+ error?: { message: string };
333
+ };
334
+
335
+ if (result.error) {
336
+ return {
337
+ content: [{ type: "text", text: `Error: ${result.error.message}` }],
338
+ };
339
+ }
340
+
341
+ if (result.result?.content) {
342
+ return {
343
+ content: result.result.content.map((c) => ({
344
+ type: "text" as const,
345
+ text: c.text || "",
346
+ })),
347
+ };
348
+ }
349
+
350
+ return { content: [{ type: "text", text: "No result" }] };
351
+ } catch (err) {
352
+ return {
353
+ content: [
354
+ {
355
+ type: "text",
356
+ text: `Tunnel connection failed: ${err instanceof Error ? err.message : String(err)}`,
357
+ },
358
+ ],
359
+ };
360
+ }
361
+ }
362
+ }
@@ -0,0 +1,38 @@
1
+ // Types for Docker MCP Cloudflare Worker
2
+
3
+ export interface Env {
4
+ MCP_AGENT: DurableObjectNamespace;
5
+ API_KEYS: KVNamespace;
6
+ }
7
+
8
+ export interface ApiKeyRecord {
9
+ userId: string;
10
+ tier: 'free' | 'standard' | 'premium';
11
+ createdAt: string;
12
+ active: boolean;
13
+ }
14
+
15
+ export interface UserState {
16
+ userId: string;
17
+ tier: 'free' | 'standard' | 'premium';
18
+ toolCallsToday: number;
19
+ lastReset: string;
20
+ // Per-tier limits
21
+ maxToolCallsPerDay: number;
22
+ }
23
+
24
+ export const TIER_LIMITS: Record<string, number> = {
25
+ free: 50, // 50 tool calls/day
26
+ standard: 500, // 500 tool calls/day (unlimited in practice for $19/mo)
27
+ premium: 5000, // 5000 tool calls/day
28
+ };
29
+
30
+ export interface ToolRequest {
31
+ name: string;
32
+ arguments: Record<string, unknown>;
33
+ }
34
+
35
+ export interface ToolResponse {
36
+ content: Array<{ type: 'text' | 'image'; text?: string; data?: string }>;
37
+ isError?: boolean;
38
+ }
package/src/server.ts CHANGED
@@ -10,6 +10,7 @@ import { registerNetworkTools } from "./tools/network.js";
10
10
  import { registerVolumeTools } from "./tools/volume.js";
11
11
  import { registerMonitoringTools } from "./tools/monitoring.js";
12
12
  import { registerSystemTools } from "./tools/system.js";
13
+ import { registerTransferTools } from "./tools/transfer.js";
13
14
 
14
15
  export interface ServerOptions {
15
16
  timeoutMs?: number;
@@ -18,7 +19,7 @@ export interface ServerOptions {
18
19
  export function createServer(docker: Dockerode, options?: ServerOptions): McpServer {
19
20
  const server = new McpServer({
20
21
  name: "docker-mcp-server",
21
- version: "0.3.3",
22
+ version: "0.3.4",
22
23
  });
23
24
 
24
25
  // Register all tool categories
@@ -32,6 +33,7 @@ export function createServer(docker: Dockerode, options?: ServerOptions): McpSer
32
33
  registerVolumeTools(server, docker);
33
34
  registerMonitoringTools(server, docker);
34
35
  registerSystemTools(server, docker);
36
+ registerTransferTools(server, docker);
35
37
 
36
38
  return server;
37
- }
39
+ }
@@ -50,7 +50,7 @@ function runCompose(path: string, args: string[]): string {
50
50
  export function registerComposeTools(server: McpServer): void {
51
51
  server.tool(
52
52
  "compose_up",
53
- "Bring up Docker Compose services from a docker-compose.yml file. Optionally build images first.",
53
+ "Bring up Docker Compose services from a docker-compose.yml file at path. Use compose_ps to check service states after bringing them up; use compose_logs to inspect output. Optionally rebuild images before starting (build=true). Returns a confirmation string listing which services were started. Idempotent: already-running services are left untouched. Returns an error string if the Compose file is missing or invalid.",
54
54
  ComposeUpSchema.shape,
55
55
  { idempotentHint: true, openWorldHint: false },
56
56
  async (params) => {
@@ -86,7 +86,7 @@ export function registerComposeTools(server: McpServer): void {
86
86
 
87
87
  server.tool(
88
88
  "compose_ps",
89
- "List service states across a Docker Compose stack.",
89
+ "List service states across a Docker Compose stack defined by docker-compose.yml at path. Returns an array of services with name, state (running, exited, etc.), health status, and port mappings. Use compose_up to start services; use compose_logs to inspect output. Read-only and safe to call repeatedly. Returns an error string if the Compose file is missing.",
90
90
  ComposePsSchema.shape,
91
91
  { readOnlyHint: true, idempotentHint: true, openWorldHint: false },
92
92
  async (params) => {
@@ -9,6 +9,8 @@ import {
9
9
  RemoveContainerSchema,
10
10
  RecreateContainerSchema,
11
11
  RunContainerSchema,
12
+ PruneContainersSchema,
13
+ UpdateContainerSchema,
12
14
  } from "../types.js";
13
15
  import { formatContainer, formatError, withRetry } from "../docker.js";
14
16
 
@@ -223,4 +225,108 @@ export function registerContainerTools(server: McpServer, docker: Dockerode): vo
223
225
  }
224
226
  }
225
227
  );
228
+
229
+ // prune_containers — remove stopped containers
230
+ server.tool(
231
+ "prune_containers",
232
+ "Remove all stopped Docker containers. Returns the number of containers removed and reclaimed disk space. This is a destructive operation — stopped containers and their non-persisted data will be deleted. Use list_containers first to see what will be removed. Useful for cleanup after deployments or when disk space is low.",
233
+ PruneContainersSchema.shape,
234
+ { readOnlyHint: false, idempotentHint: false, openWorldHint: false },
235
+ async (params) => {
236
+ try {
237
+ const filterObj: Record<string, string[]> = {};
238
+ if (params.filter) {
239
+ const parts = params.filter.split('=');
240
+ if (parts.length === 2) {
241
+ filterObj[parts[0]] = [parts[1]];
242
+ }
243
+ }
244
+ const result = await withRetry(
245
+ () => docker.pruneContainers({ filters: filterObj }),
246
+ { label: "prune_containers" }
247
+ );
248
+ return {
249
+ content: [{
250
+ type: "text",
251
+ text: JSON.stringify({
252
+ containers_deleted: (result.ContainersDeleted || []).length,
253
+ space_reclaimed: result.SpaceReclaimed || 0,
254
+ space_reclaimed_human: formatBytes(result.SpaceReclaimed || 0),
255
+ deleted_ids: (result.ContainersDeleted || []).map((id: string) => id.substring(0, 12)),
256
+ }, null, 2),
257
+ }],
258
+ };
259
+ } catch (error) {
260
+ return { content: [{ type: "text", text: `Error: ${formatError(error)}` }], isError: true };
261
+ }
262
+ }
263
+ );
264
+
265
+ // update_container — update container resource limits
266
+ server.tool(
267
+ "update_container",
268
+ "Update a Docker container's resource limits (CPU, memory, CPU shares). Requires the container to be stopped first. Returns the updated resource limits. Use this to right-size containers based on actual usage — set CPU limits to prevent runaway processes and memory limits to prevent OOM kills.",
269
+ UpdateContainerSchema.shape,
270
+ { readOnlyHint: false, idempotentHint: false, openWorldHint: false },
271
+ async (params) => {
272
+ try {
273
+ const updateConfig: Record<string, any> = {};
274
+ if (params.cpu_limit !== undefined) {
275
+ updateConfig.NanoCpus = Math.round(params.cpu_limit * 1e9);
276
+ }
277
+ if (params.memory_limit !== undefined) {
278
+ updateConfig.Memory = parseMemory(params.memory_limit);
279
+ }
280
+ if (params.cpu_shares !== undefined) {
281
+ updateConfig.CpuShares = params.cpu_shares;
282
+ }
283
+
284
+ if (Object.keys(updateConfig).length === 0) {
285
+ return { content: [{ type: "text", text: "Error: No resource limits specified. Provide at least one of: cpu_limit, memory_limit, cpu_shares." }], isError: true };
286
+ }
287
+
288
+ const container = docker.getContainer(params.container_id);
289
+ await withRetry(() => container.update(updateConfig), { label: "update_container" });
290
+
291
+ // Inspect to return current state
292
+ const info = await withRetry(() => container.inspect(), { label: "update_container_inspect" });
293
+ const hostConfig = info.HostConfig || {};
294
+
295
+ return {
296
+ content: [{
297
+ type: "text",
298
+ text: JSON.stringify({
299
+ container: params.container_id,
300
+ state: info.State?.Status,
301
+ resource_limits: {
302
+ cpu_limit_cores: hostConfig.NanoCpus ? hostConfig.NanoCpus / 1e9 : null,
303
+ memory_limit: hostConfig.Memory || null,
304
+ memory_limit_human: hostConfig.Memory ? formatBytes(hostConfig.Memory) : null,
305
+ cpu_shares: hostConfig.CpuShares || null,
306
+ },
307
+ }, null, 2),
308
+ }],
309
+ };
310
+ } catch (error) {
311
+ return { content: [{ type: "text", text: `Error: ${formatError(error)}` }], isError: true };
312
+ }
313
+ }
314
+ );
315
+ }
316
+
317
+ function formatBytes(bytes: number): string {
318
+ if (bytes === 0) return '0 B';
319
+ const k = 1024;
320
+ const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
321
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
322
+ return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`;
323
+ }
324
+
325
+ function parseMemory(mem: string): number {
326
+ const match = mem.match(/^(\d+)(b|k|m|g|t)?$/i);
327
+ if (!match) throw new Error(`Invalid memory format: ${mem}`);
328
+ const value = parseInt(match[1]);
329
+ const unit = (match[2] || 'b').toLowerCase();
330
+ const multipliers: Record<string, number> = { b: 1, k: 1024, m: 1024**2, g: 1024**3, t: 1024**4 };
331
+ return value * (multipliers[unit] || 1);
226
332
  }
@@ -130,7 +130,7 @@ export function registerHealthTools(server: McpServer, docker: Dockerode): void
130
130
 
131
131
  server.tool(
132
132
  "set_restart_policy",
133
- "Change the restart policy of a running container without recreating it.",
133
+ "Change the restart policy of a running container without recreating it. Use restart_container for an immediate restart; use this tool to change the policy (always, unless-stopped, on-failure, no) for future restarts. Returns a confirmation string on success. Idempotent: setting the same policy is a no-op. Returns an error string if the container does not exist.",
134
134
  SetRestartPolicySchema.shape,
135
135
  { idempotentHint: true, openWorldHint: false },
136
136
  async (params) => {
@@ -5,6 +5,7 @@ import {
5
5
  PullImageSchema,
6
6
  BuildImageSchema,
7
7
  RemoveImageSchema,
8
+ PruneImagesSchema,
8
9
  } from "../types.js";
9
10
  import { formatImage, formatError, withRetry } from "../docker.js";
10
11
 
@@ -30,7 +31,7 @@ export function registerImageTools(server: McpServer, docker: Dockerode): void {
30
31
 
31
32
  server.tool(
32
33
  "pull_image",
33
- "Pull a Docker image from a registry. Returns pull progress events.",
34
+ "Pull a Docker image from a registry by image name (e.g. nginx:latest). Use list_images to see locally available images after pulling. Returns pull progress events as text. Idempotent: pulling an already-up-to-date image is a no-op. Returns an error string if the image does not exist on the registry or the pull fails.",
34
35
  PullImageSchema.shape,
35
36
  { idempotentHint: true, openWorldHint: false },
36
37
  async (params) => {
@@ -93,4 +94,56 @@ export function registerImageTools(server: McpServer, docker: Dockerode): void {
93
94
  }
94
95
  }
95
96
  );
97
+
98
+ // prune_images — remove unused Docker images
99
+ server.tool(
100
+ "prune_images",
101
+ "Remove unused Docker images (dangling and unreferenced). Returns the number of images deleted and reclaimed disk space. Only removes images not used by any container. Use list_images first to see what will be removed. Useful for reclaiming disk space after builds or when switching base images frequently.",
102
+ PruneImagesSchema.shape,
103
+ { readOnlyHint: false, idempotentHint: false, openWorldHint: false },
104
+ async (params) => {
105
+ try {
106
+ const filterObj: Record<string, string[]> = {};
107
+ if (params.filter) {
108
+ try {
109
+ const parsed = JSON.parse(params.filter);
110
+ Object.assign(filterObj, parsed);
111
+ } catch {
112
+ // If not JSON, try key=value format
113
+ const parts = params.filter.split('=');
114
+ if (parts.length === 2) {
115
+ filterObj[parts[0]] = [parts[1]];
116
+ }
117
+ }
118
+ }
119
+ const result = await withRetry(
120
+ () => docker.pruneImages({ filters: filterObj }),
121
+ { label: "prune_images" }
122
+ );
123
+ return {
124
+ content: [{
125
+ type: "text",
126
+ text: JSON.stringify({
127
+ images_deleted: (result.ImagesDeleted || []).length,
128
+ space_reclaimed: result.SpaceReclaimed || 0,
129
+ space_reclaimed_human: formatBytes(result.SpaceReclaimed || 0),
130
+ deleted_ids: (result.ImagesDeleted || []).map((img: any) =>
131
+ typeof img === 'string' ? img.substring(0, 19) : img.Deleted?.substring(0, 19) || 'unknown'
132
+ ),
133
+ }, null, 2),
134
+ }],
135
+ };
136
+ } catch (error) {
137
+ return { content: [{ type: "text", text: `Error: ${formatError(error)}` }], isError: true };
138
+ }
139
+ }
140
+ );
141
+ }
142
+
143
+ function formatBytes(bytes: number): string {
144
+ if (bytes === 0) return '0 B';
145
+ const k = 1024;
146
+ const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
147
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
148
+ return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`;
96
149
  }