We provide all the information about MCP servers via our MCP API.
curl -X GET 'https://glama.ai/api/mcp/v1/servers/jmagar/homelab-mcp'
If you have feedback or need assistance with the MCP directory API, please join our Discord server
import type { HostConfig, ImageInfo, ListImagesOptions } from "../../types.js";
import { narrowToDefaultHost } from "../../utils/host-utils.js";
import type { ClientManager } from "./utils/client-manager.js";
import { formatImageId } from "./utils/formatters.js";
/**
* Service for Docker image operations.
* Handles image lifecycle, pulling, building, and management.
*/
export class ImageService {
constructor(private clientManager: ClientManager) {}
/**
* List images across multiple hosts with optional filtering.
*
* @param hosts - List of Docker hosts to query
* @param options - Filtering options (danglingOnly)
* @returns Promise resolving to array of image information
*/
async listImages(hosts: HostConfig[], options: ListImagesOptions = {}): Promise<ImageInfo[]> {
const targetHosts = narrowToDefaultHost(hosts);
const results = await Promise.allSettled(
targetHosts.map((host) => this.listImagesOnHost(host, options))
);
return results
.filter((r): r is PromiseFulfilledResult<ImageInfo[]> => r.status === "fulfilled")
.flatMap((r) => r.value);
}
/**
* List images from a single host (internal helper).
*/
private async listImagesOnHost(
host: HostConfig,
options: ListImagesOptions
): Promise<ImageInfo[]> {
const docker = await this.clientManager.getClient(host);
const images = await docker.listImages({
filters: options.danglingOnly ? { dangling: ["true"] } : undefined,
});
return images.map((img) => ({
id: formatImageId(img.Id),
tags: img.RepoTags || ["<none>:<none>"],
size: img.Size,
created: new Date(img.Created * 1000).toISOString(),
containers: img.Containers || 0,
hostName: host.name,
}));
}
/**
* Pull an image from a registry.
*
* @param imageName - Image name with optional tag (e.g., "nginx:latest")
* @param host - Host configuration
* @returns Promise resolving to pull status
* @throws Error if image name is empty or pull fails
*/
async pullImage(imageName: string, host: HostConfig): Promise<{ status: string }> {
if (!imageName || imageName.trim() === "") {
throw new Error("Image name is required");
}
const docker = await this.clientManager.getClient(host);
return new Promise((resolve, reject) => {
docker.pull(imageName, (err: Error | null, stream: NodeJS.ReadableStream) => {
if (err) {
reject(new Error(`Failed to pull image: ${err.message}`));
return;
}
docker.modem.followProgress(stream, (err: Error | null) => {
if (err) {
reject(new Error(`Pull failed: ${err.message}`));
} else {
resolve({ status: `Successfully pulled ${imageName}` });
}
});
});
});
}
/**
* Remove an image from a host.
*
* @param imageId - Image ID or tag
* @param host - Host configuration
* @param options - Remove options (force)
* @returns Promise resolving to removal status
*/
async removeImage(
imageId: string,
host: HostConfig,
options: { force?: boolean } = {}
): Promise<{ status: string }> {
const docker = await this.clientManager.getClient(host);
const image = docker.getImage(imageId);
await image.remove({ force: options.force });
return { status: `Successfully removed image ${imageId}` };
}
/**
* Build an image from a Dockerfile (SSH-based for remote hosts).
*
* SECURITY: Implements path traversal protection (CWE-22)
* - Requires absolute paths for context and dockerfile
* - Rejects any path containing .. or . components
* - Validates character set to prevent injection
*
* @param host - Docker host configuration
* @param options - Build options (context, tag, dockerfile, noCache)
* @returns Promise resolving to build status
* @throws Error if paths contain directory traversal or invalid characters
*/
async buildImage(
host: HostConfig,
options: {
context: string;
tag: string;
dockerfile?: string;
noCache?: boolean;
}
): Promise<{ status: string }> {
// For remote builds, we need to use SSH and docker build command
// dockerode's build() requires local tar stream which won't work for remote
const { context, tag, dockerfile, noCache } = options;
// Validate inputs
if (!/^[a-zA-Z0-9._\-/:]+$/.test(tag)) {
throw new Error(`Invalid image tag: ${tag}`);
}
// Use secure path validation (prevents directory traversal)
const { validateSecurePath } = await import("../../utils/path-security.js");
validateSecurePath(context, "context");
if (dockerfile) {
validateSecurePath(dockerfile, "dockerfile");
}
const args: string[] = ["build", "-t", tag];
if (noCache) {
args.push("--no-cache");
}
if (dockerfile) {
args.push("-f", dockerfile);
}
args.push(context);
// Execute via SSH for remote hosts, or locally for socket connections
if (host.host.startsWith("/")) {
// Local socket - use docker directly
const { execFile } = await import("node:child_process");
const { promisify } = await import("node:util");
const execFileAsync = promisify(execFile);
await execFileAsync("docker", args, { timeout: 600000 }); // 10 min timeout for builds
} else {
// Remote - use SSH
const { validateHostForSsh } = await import("../ssh.js");
const { validateAlphanumeric } = await import("../../utils/validation.js");
const { execFile } = await import("node:child_process");
const { promisify } = await import("node:util");
const execFileAsync = promisify(execFile);
validateHostForSsh(host);
validateAlphanumeric(host.name, "host name");
const sshArgs = [
"-o",
"BatchMode=yes",
"-o",
"ConnectTimeout=5",
"-o",
"StrictHostKeyChecking=accept-new",
host.name,
`docker ${args.join(" ")}`,
];
await execFileAsync("ssh", sshArgs, { timeout: 600000 });
}
return { status: `Successfully built image ${tag}` };
}
/**
* Recreate a container (stop, remove, pull latest, start with same config).
*
* @param containerId - Container ID or name
* @param host - Host configuration
* @param options - Recreate options (pull)
* @returns Promise resolving to recreation status and new container ID
*/
async recreateContainer(
containerId: string,
host: HostConfig,
options: { pull?: boolean } = {}
): Promise<{ status: string; containerId: string }> {
const docker = await this.clientManager.getClient(host);
const container = docker.getContainer(containerId);
// Get current container config
const info = await container.inspect();
const imageName = info.Config.Image;
// Stop container if running
if (info.State.Running) {
await container.stop();
}
// Remove container
await container.remove();
// Pull latest image if requested
if (options.pull !== false) {
await this.pullImage(imageName, host);
}
// Create new container with same config
const newContainer = await docker.createContainer({
...info.Config,
HostConfig: info.HostConfig,
NetworkingConfig: {
EndpointsConfig: info.NetworkSettings.Networks,
},
});
// Start new container
await newContainer.start();
return {
status: "Container recreated successfully",
containerId: newContainer.id,
};
}
}