import { describe, it, expect, vi, beforeEach } from "vitest";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { registerUnifiedTool } from "./unified.js";
import type { HostConfig } from "../types.js";
import type { ServiceContainer } from "../services/container.js";
import type { IDockerService, IComposeService, ISSHService, IFileService } from "../services/interfaces.js";
// Create mock services
const createMockDockerService = (): IDockerService => ({
listContainers: vi.fn().mockResolvedValue([]),
getContainerStats: vi.fn().mockResolvedValue({
containerId: "abc123",
containerName: "my-container",
cpuPercent: 25.5,
memoryUsage: 512000000,
memoryLimit: 2000000000,
memoryPercent: 25.6,
networkRx: 1024000,
networkTx: 512000,
blockRead: 2048000,
blockWrite: 1024000,
pids: 10
}),
inspectContainer: vi.fn().mockResolvedValue({
Id: "abc123456789",
Name: "/my-container",
Config: {
Image: "nginx:latest",
Env: [],
Labels: {}
},
State: {
Status: "running",
Running: true,
StartedAt: "2024-01-01T10:00:00Z"
},
Created: "2024-01-01T09:00:00Z",
RestartCount: 0,
NetworkSettings: {
Ports: {},
Networks: {}
},
Mounts: []
}),
containerAction: vi.fn().mockResolvedValue(undefined),
getContainerLogs: vi.fn().mockResolvedValue([
{ timestamp: "2024-01-01T10:00:00Z", stream: "stdout", message: "log line 1" },
{ timestamp: "2024-01-01T10:00:01Z", stream: "stdout", message: "error log line 2" },
{ timestamp: "2024-01-01T10:00:02Z", stream: "stderr", message: "log line 3" }
]),
pullImageForContainer: vi.fn().mockResolvedValue({ status: "Image pulled successfully" }),
recreateContainer: vi.fn().mockResolvedValue({ status: "Container recreated", containerId: "new-abc123456789" }),
getDockerInfo: vi.fn().mockResolvedValue({
Containers: 10,
ContainersRunning: 8,
ContainersPaused: 1,
ContainersStopped: 1,
Images: 25,
ServerVersion: "20.10.21",
MemTotal: 8000000000
}),
getDockerDiskUsage: vi.fn().mockResolvedValue({
LayersSize: 5000000000,
Images: [{ size: 2000000000 }],
Containers: [{ SizeRw: 100000000 }],
Volumes: [{ UsageData: { Size: 500000000 } }]
}),
pruneDocker: vi.fn().mockResolvedValue([
{ host: "testhost", type: "containers", spaceReclaimed: 1000000000 }
]),
listImages: vi.fn().mockResolvedValue([
{
id: "sha256:abc123",
tags: ["nginx:latest"],
size: 142 * 1024 * 1024,
created: "2024-01-01T10:00:00Z",
containers: 1,
hostName: "testhost"
}
]),
pullImage: vi.fn().mockResolvedValue({ status: "Image pulled successfully" }),
buildImage: vi.fn().mockResolvedValue(undefined),
removeImage: vi.fn().mockResolvedValue(undefined),
clearClients: vi.fn()
});
const createMockComposeService = (): IComposeService => ({
listProjects: vi.fn().mockResolvedValue([
{
name: "myapp",
status: "running",
configFiles: ["/opt/myapp/docker-compose.yaml"],
services: ["web", "db"]
},
{
name: "webapp",
status: "partial",
configFiles: ["/opt/webapp/docker-compose.yaml"],
services: ["frontend"]
}
]),
getStatus: vi.fn().mockResolvedValue({
name: "myapp",
status: "running",
configFiles: ["/opt/myapp/docker-compose.yaml"],
services: [
{
name: "web",
status: "running",
health: "healthy",
publishers: [{ publishedPort: 8080, targetPort: 80, protocol: "tcp" }]
},
{
name: "db",
status: "running",
publishers: [{ publishedPort: 5432, targetPort: 5432, protocol: "tcp" }]
}
]
}),
up: vi.fn().mockResolvedValue("Started project"),
down: vi.fn().mockResolvedValue("Stopped project"),
restart: vi.fn().mockResolvedValue("Restarted project"),
logs: vi.fn().mockResolvedValue("log line 1\nlog line 2\nerror log line 3"),
build: vi.fn().mockResolvedValue("Built images"),
pull: vi.fn().mockResolvedValue("Pulled images"),
recreate: vi.fn().mockResolvedValue("Recreated containers")
});
const createMockSSHService = (): ISSHService => ({
executeSSHCommand: vi.fn().mockResolvedValue("command output"),
getHostResources: vi.fn().mockResolvedValue({
hostname: "testhost",
cpu: { cores: 8, loadAverage: [1.5, 2.0, 1.8] },
memory: { total: 16000000000, used: 8000000000, free: 8000000000 },
disk: { total: 500000000000, used: 250000000000, free: 250000000000 },
uptime: 86400
})
});
const createMockFileService = (): IFileService => ({
readFile: vi.fn().mockResolvedValue({ content: "", size: 0, truncated: false }),
listDirectory: vi.fn().mockResolvedValue(""),
treeDirectory: vi.fn().mockResolvedValue(""),
executeCommand: vi.fn().mockResolvedValue({ stdout: "", exitCode: 0 }),
findFiles: vi.fn().mockResolvedValue(""),
transferFile: vi.fn().mockResolvedValue({ bytesTransferred: 0 }),
diffFiles: vi.fn().mockResolvedValue("")
});
// Mock services stored at module level for test access
let mockDockerService: IDockerService;
let mockComposeService: IComposeService;
let mockSSHService: ISSHService;
let mockFileService: IFileService;
const createMockContainer = (): ServiceContainer => {
mockDockerService = createMockDockerService();
mockComposeService = createMockComposeService();
mockSSHService = createMockSSHService();
mockFileService = createMockFileService();
return {
getDockerService: () => mockDockerService,
setDockerService: vi.fn(),
getSSHConnectionPool: vi.fn(),
setSSHConnectionPool: vi.fn(),
getSSHService: () => mockSSHService,
setSSHService: vi.fn(),
getComposeService: () => mockComposeService,
setComposeService: vi.fn(),
getFileService: () => mockFileService,
setFileService: vi.fn(),
cleanup: vi.fn().mockResolvedValue(undefined)
} as unknown as ServiceContainer;
};
describe("unified tool integration", () => {
let mockServer: McpServer;
let toolHandler: (params: unknown) => Promise<unknown>;
let mockContainer: ServiceContainer;
beforeEach(async () => {
vi.clearAllMocks();
mockContainer = createMockContainer();
const registeredTools = new Map<string, (params: unknown) => Promise<unknown>>();
mockServer = {
registerTool: vi.fn((name, _config, handler) => {
registeredTools.set(name, handler);
})
} as unknown as McpServer;
registerUnifiedTool(mockServer, mockContainer);
const handler = registeredTools.get("homelab");
if (!handler) throw new Error("Tool handler not registered");
toolHandler = handler;
});
describe("container actions", () => {
it("should handle container list with valid params", async () => {
const result = await toolHandler({
action: "container",
subaction: "list",
state: "running"
});
expect(result).toBeDefined();
expect(typeof result).toBe("object");
});
it("should return error for invalid container subaction", async () => {
const result = (await toolHandler({
action: "container",
subaction: "invalid_action"
})) as { isError: boolean; content: Array<{ text: string }> };
expect(result.isError).toBe(true);
// Zod validation now catches invalid subactions before reaching handler
expect(result.content[0].text).toContain("Error");
});
it.skip("should handle container stats request (slow - requires Docker)", async () => {
const result = await toolHandler({
action: "container",
subaction: "stats"
});
expect(result).toBeDefined();
}, 30000);
it("should handle container search request", async () => {
const result = await toolHandler({
action: "container",
subaction: "search",
query: "plex"
});
expect(result).toBeDefined();
});
describe("container state control actions", () => {
it("should start container by name", async () => {
const dockerService = await import("../services/docker.js");
const result = (await toolHandler({
action: "container",
subaction: "start",
container_id: "my-container",
host: "testhost"
})) as { content: Array<{ text: string }> };
expect(dockerService.containerAction).toHaveBeenCalledWith(
"my-container",
"start",
expect.objectContaining({ name: "testhost" })
);
expect(result.content).toBeDefined();
expect(result.content[0].text).toContain("Successfully performed 'start'");
expect(result.content[0].text).toContain("my-container");
});
it("should stop container by name", async () => {
const dockerService = await import("../services/docker.js");
const result = (await toolHandler({
action: "container",
subaction: "stop",
container_id: "my-container",
host: "testhost"
})) as { content: Array<{ text: string }> };
expect(dockerService.containerAction).toHaveBeenCalledWith(
"my-container",
"stop",
expect.objectContaining({ name: "testhost" })
);
expect(result.content).toBeDefined();
expect(result.content[0].text).toContain("Successfully performed 'stop'");
expect(result.content[0].text).toContain("my-container");
});
it("should restart container by name", async () => {
const dockerService = await import("../services/docker.js");
const result = (await toolHandler({
action: "container",
subaction: "restart",
container_id: "my-container",
host: "testhost"
})) as { content: Array<{ text: string }> };
expect(dockerService.containerAction).toHaveBeenCalledWith(
"my-container",
"restart",
expect.objectContaining({ name: "testhost" })
);
expect(result.content).toBeDefined();
expect(result.content[0].text).toContain("Successfully performed 'restart'");
expect(result.content[0].text).toContain("my-container");
});
it("should pause container by name", async () => {
const dockerService = await import("../services/docker.js");
const result = (await toolHandler({
action: "container",
subaction: "pause",
container_id: "my-container",
host: "testhost"
})) as { content: Array<{ text: string }> };
expect(dockerService.containerAction).toHaveBeenCalledWith(
"my-container",
"pause",
expect.objectContaining({ name: "testhost" })
);
expect(result.content).toBeDefined();
expect(result.content[0].text).toContain("Successfully performed 'pause'");
expect(result.content[0].text).toContain("my-container");
});
it("should unpause container by name", async () => {
const dockerService = await import("../services/docker.js");
const result = (await toolHandler({
action: "container",
subaction: "unpause",
container_id: "my-container",
host: "testhost"
})) as { content: Array<{ text: string }> };
expect(dockerService.containerAction).toHaveBeenCalledWith(
"my-container",
"unpause",
expect.objectContaining({ name: "testhost" })
);
expect(result.content).toBeDefined();
expect(result.content[0].text).toContain("Successfully performed 'unpause'");
expect(result.content[0].text).toContain("my-container");
});
});
describe("container action: stats", () => {
it("should get container stats for single host", async () => {
const dockerService = await import("../services/docker.js");
vi.spyOn(dockerService, "getContainerStats").mockResolvedValue({
containerId: "abc123",
containerName: "my-container",
cpuPercent: 25.5,
memoryUsage: 512 * 1024 * 1024, // 512MB
memoryLimit: 2 * 1024 * 1024 * 1024, // 2GB
memoryPercent: 25.0,
networkRx: 1.5 * 1024 * 1024, // 1.5MB
networkTx: 2 * 1024 * 1024, // 2MB
blockRead: 0,
blockWrite: 0
});
const result = (await toolHandler({
action: "container",
subaction: "stats",
container_id: "my-container",
host: "testhost"
})) as { content: Array<{ text: string }> };
expect(dockerService.getContainerStats).toHaveBeenCalledWith(
"my-container",
expect.objectContaining({ name: "testhost" })
);
expect(result.content).toBeDefined();
expect(result.content[0].text).toContain("CPU");
expect(result.content[0].text).toContain("25.5");
});
it("should get container stats across all hosts when host not specified", async () => {
const dockerService = await import("../services/docker.js");
const result = (await toolHandler({
action: "container",
subaction: "stats",
container_id: "my-container"
// No host specified - should search all hosts
})) as { content: Array<{ text: string }> };
expect(dockerService.findContainerHost).toHaveBeenCalledWith(
"my-container",
expect.any(Array)
);
expect(result.content).toBeDefined();
});
it("should get stats for all containers when container_id not specified", async () => {
const result = (await toolHandler({
action: "container",
subaction: "stats",
response_format: "json"
})) as { content: Array<{ text: string }> };
expect(result.content).toBeDefined();
// Should return JSON with stats array
const output = JSON.parse(result.content[0].text);
expect(output.stats).toBeDefined();
expect(Array.isArray(output.stats)).toBe(true);
});
});
describe("container action: inspect", () => {
it("should inspect container with summary mode (default)", async () => {
const dockerService = await import("../services/docker.js");
vi.spyOn(dockerService, "inspectContainer").mockResolvedValue({
Id: "abc123456789",
Name: "/my-container",
Config: {
Image: "nginx:latest",
Env: ["NODE_ENV=production", "PORT=3000", "API_KEY=secret123"],
Labels: { "com.docker.compose.project": "myapp", version: "1.0" }
},
State: {
Status: "running",
Running: true,
StartedAt: "2024-01-01T10:00:00Z"
},
Created: "2024-01-01T09:00:00Z",
RestartCount: 0,
NetworkSettings: {
Ports: {
"80/tcp": [{ HostIp: "0.0.0.0", HostPort: "8080" }]
},
Networks: {
bridge: {}
}
},
Mounts: [{ Source: "/data", Destination: "/app/data", Type: "bind", Mode: "rw" }]
});
const result = (await toolHandler({
action: "container",
subaction: "inspect",
container_id: "my-container",
host: "testhost"
})) as { content: Array<{ text: string }> };
expect(dockerService.inspectContainer).toHaveBeenCalledWith(
"my-container",
expect.objectContaining({ name: "testhost" })
);
// Summary should include basic info
expect(result.content[0].text).toContain("my-container");
expect(result.content[0].text).toContain("running");
expect(result.content[0].text).toContain("nginx:latest");
// Summary should show counts for env/labels, not full details
expect(result.content[0].text).toContain("Env Vars");
expect(result.content[0].text).toContain("3"); // env count
expect(result.content[0].text).toContain("Labels");
expect(result.content[0].text).toContain("2"); // labels count
// Summary should NOT show individual environment variables
expect(result.content[0].text).not.toContain("NODE_ENV");
expect(result.content[0].text).not.toContain("production");
});
it("should inspect container with full detail mode", async () => {
const dockerService = await import("../services/docker.js");
vi.spyOn(dockerService, "inspectContainer").mockResolvedValue({
Id: "abc123456789",
Name: "/my-container",
Config: {
Image: "nginx:latest",
Cmd: ["nginx", "-g", "daemon off;"],
WorkingDir: "/app",
Env: ["NODE_ENV=production", "PORT=3000", "DATABASE_PASSWORD=secret123"],
Labels: { "com.docker.compose.project": "myapp" }
},
State: {
Status: "running",
Running: true,
StartedAt: "2024-01-01T10:00:00Z"
},
Created: "2024-01-01T09:00:00Z",
RestartCount: 2,
NetworkSettings: {
Ports: {
"80/tcp": [{ HostIp: "0.0.0.0", HostPort: "8080" }]
},
Networks: {
bridge: {},
custom_network: {}
}
},
Mounts: [
{ Source: "/data", Destination: "/app/data", Type: "bind", Mode: "rw" },
{ Source: "/config", Destination: "/etc/nginx", Type: "volume", Mode: "ro" }
]
});
const result = (await toolHandler({
action: "container",
subaction: "inspect",
container_id: "my-container",
host: "testhost",
summary: false // Full detail mode
})) as { content: Array<{ text: string }> };
expect(dockerService.inspectContainer).toHaveBeenCalledWith(
"my-container",
expect.objectContaining({ name: "testhost" })
);
// Full detail should include environment variables
expect(result.content[0].text).toContain("NODE_ENV");
expect(result.content[0].text).toContain("production");
// Sensitive variables should be masked
expect(result.content[0].text).toContain("DATABASE_PASSWORD=****");
// Full detail should include mounts
expect(result.content[0].text).toContain("/data");
expect(result.content[0].text).toContain("/app/data");
// Full detail should include networks
expect(result.content[0].text).toContain("bridge");
expect(result.content[0].text).toContain("custom_network");
// Full detail should include working dir and command
expect(result.content[0].text).toContain("/app");
expect(result.content[0].text).toContain("nginx");
});
});
describe("container action: logs", () => {
it("should get container logs without grep filter", async () => {
const dockerService = await import("../services/docker.js");
const result = (await toolHandler({
action: "container",
subaction: "logs",
container_id: "my-container",
host: "testhost"
})) as { content: Array<{ text: string }> };
expect(dockerService.getContainerLogs).toHaveBeenCalledWith(
"my-container",
expect.objectContaining({ name: "testhost" }),
expect.objectContaining({})
);
expect(result.content).toBeDefined();
expect(result.content[0].text).toContain("log line 1");
expect(result.content[0].text).toContain("log line 2");
expect(result.content[0].text).toContain("log line 3");
});
it("should get container logs with grep filter", async () => {
const dockerService = await import("../services/docker.js");
const result = (await toolHandler({
action: "container",
subaction: "logs",
container_id: "my-container",
host: "testhost",
grep: "error"
})) as { content: Array<{ text: string }> };
expect(dockerService.getContainerLogs).toHaveBeenCalledWith(
"my-container",
expect.objectContaining({ name: "testhost" }),
expect.objectContaining({})
);
expect(result.content).toBeDefined();
// Should only include the error log line after grep filtering
expect(result.content[0].text).toContain("error log line 2");
expect(result.content[0].text).not.toContain("log line 1");
expect(result.content[0].text).not.toContain("log line 3");
});
it("should get container logs with lines parameter", async () => {
const dockerService = await import("../services/docker.js");
const result = (await toolHandler({
action: "container",
subaction: "logs",
container_id: "my-container",
host: "testhost",
lines: 100
})) as { content: Array<{ text: string }> };
expect(dockerService.getContainerLogs).toHaveBeenCalledWith(
"my-container",
expect.objectContaining({ name: "testhost" }),
expect.objectContaining({ lines: 100 })
);
expect(result.content).toBeDefined();
expect(result.content[0].text).toContain("log line 1");
});
});
describe("container action: pull", () => {
it("should pull latest image for container", async () => {
const dockerService = await import("../services/docker.js");
const result = (await toolHandler({
action: "container",
subaction: "pull",
container_id: "my-container",
host: "testhost"
})) as { content: Array<{ text: string }> };
// Should first inspect container to get image name
expect(dockerService.inspectContainer).toHaveBeenCalledWith(
"my-container",
expect.objectContaining({ name: "testhost" })
);
// Then pull the image (not the container name)
expect(dockerService.pullImage).toHaveBeenCalledWith(
"nginx:latest", // Image name from inspect
expect.objectContaining({ name: "testhost" })
);
expect(result.content).toBeDefined();
expect(result.content[0].text).toContain("pulled latest image");
expect(result.content[0].text).toContain("nginx:latest");
expect(result.content[0].text).toContain("my-container");
});
});
describe("container action: recreate", () => {
it("should recreate container with latest image", async () => {
const dockerService = await import("../services/docker.js");
const result = (await toolHandler({
action: "container",
subaction: "recreate",
container_id: "my-container",
host: "testhost"
})) as { content: Array<{ text: string }> };
expect(dockerService.recreateContainer).toHaveBeenCalledWith(
"my-container",
expect.objectContaining({ name: "testhost" }),
expect.objectContaining({})
);
expect(result.content).toBeDefined();
// Actual output format: "✓ Container recreated. New container ID: new-abc123"
expect(result.content[0].text).toContain("Container recreated");
expect(result.content[0].text).toContain("new-abc123");
});
});
});
describe("compose actions", () => {
let composeService: typeof import("../services/compose.js");
beforeEach(async () => {
composeService = await import("../services/compose.js");
});
it("should return error for compose action without host", async () => {
const result = (await toolHandler({
action: "compose",
subaction: "list"
// missing host param
})) as { isError: boolean };
expect(result.isError).toBe(true);
});
describe("compose action: list", () => {
it("should list all compose projects", async () => {
const result = (await toolHandler({
action: "compose",
subaction: "list",
host: "testhost"
})) as { content: Array<{ text: string }> };
expect(composeService.listComposeProjects).toHaveBeenCalledWith(
expect.objectContaining({ name: "testhost" })
);
expect(result.content).toBeDefined();
expect(result.content[0].text).toContain("myapp");
expect(result.content[0].text).toContain("webapp");
});
it("should list compose projects with pagination", async () => {
const result = (await toolHandler({
action: "compose",
subaction: "list",
host: "testhost",
offset: 1,
limit: 1,
response_format: "json"
})) as { content: Array<{ text: string }> };
expect(composeService.listComposeProjects).toHaveBeenCalledWith(
expect.objectContaining({ name: "testhost" })
);
expect(result.content).toBeDefined();
const output = JSON.parse(result.content[0].text);
expect(output.offset).toBe(1);
expect(output.count).toBe(1);
expect(output.projects).toHaveLength(1);
expect(output.projects[0].name).toBe("webapp");
});
it("should filter compose projects by name", async () => {
const result = (await toolHandler({
action: "compose",
subaction: "list",
host: "testhost",
name_filter: "webapp"
})) as { content: Array<{ text: string }> };
expect(result.content).toBeDefined();
expect(result.content[0].text).toContain("webapp");
expect(result.content[0].text).not.toContain("myapp");
});
});
describe("compose action: status", () => {
it("should get compose project status", async () => {
const result = (await toolHandler({
action: "compose",
subaction: "status",
project: "myapp",
host: "testhost"
})) as { content: Array<{ text: string }> };
expect(composeService.getComposeStatus).toHaveBeenCalledWith(
expect.objectContaining({ name: "testhost" }),
"myapp"
);
expect(result.content).toBeDefined();
expect(result.content[0].text).toContain("web");
expect(result.content[0].text).toContain("running");
expect(result.content[0].text).toContain("db");
});
it("should get compose status with service filter", async () => {
const result = (await toolHandler({
action: "compose",
subaction: "status",
project: "myapp",
host: "testhost",
service_filter: "web"
})) as { content: Array<{ text: string }> };
expect(composeService.getComposeStatus).toHaveBeenCalled();
expect(result.content).toBeDefined();
expect(result.content[0].text).toContain("web");
// Should filter out db service
});
it("should get compose status with pagination", async () => {
const result = (await toolHandler({
action: "compose",
subaction: "status",
project: "myapp",
host: "testhost",
offset: 1,
limit: 1,
response_format: "json"
})) as { content: Array<{ text: string }> };
expect(result.content).toBeDefined();
const output = JSON.parse(result.content[0].text);
expect(output.offset).toBe(1);
expect(output.count).toBe(1);
});
});
describe("compose action: lifecycle operations", () => {
it("should start compose project with up", async () => {
const result = (await toolHandler({
action: "compose",
subaction: "up",
project: "myapp",
host: "testhost"
})) as { content: Array<{ text: string }> };
expect(composeService.composeUp).toHaveBeenCalledWith(
expect.objectContaining({ name: "testhost" }),
"myapp",
true // detach default
);
expect(result.content).toBeDefined();
expect(result.content[0].text).toContain("Started project");
expect(result.content[0].text).toContain("myapp");
});
it("should start compose project without detach", async () => {
const result = (await toolHandler({
action: "compose",
subaction: "up",
project: "myapp",
host: "testhost",
detach: false
})) as { content: Array<{ text: string }> };
expect(composeService.composeUp).toHaveBeenCalledWith(
expect.objectContaining({ name: "testhost" }),
"myapp",
false
);
expect(result.content).toBeDefined();
});
it("should stop compose project with down", async () => {
const result = (await toolHandler({
action: "compose",
subaction: "down",
project: "myapp",
host: "testhost"
})) as { content: Array<{ text: string }> };
expect(composeService.composeDown).toHaveBeenCalledWith(
expect.objectContaining({ name: "testhost" }),
"myapp",
false // remove_volumes default
);
expect(result.content).toBeDefined();
expect(result.content[0].text).toContain("Stopped project");
expect(result.content[0].text).toContain("myapp");
});
it("should stop compose project and remove volumes", async () => {
const result = (await toolHandler({
action: "compose",
subaction: "down",
project: "myapp",
host: "testhost",
remove_volumes: true
})) as { content: Array<{ text: string }> };
expect(composeService.composeDown).toHaveBeenCalledWith(
expect.objectContaining({ name: "testhost" }),
"myapp",
true
);
expect(result.content).toBeDefined();
});
it("should restart compose project", async () => {
const result = (await toolHandler({
action: "compose",
subaction: "restart",
project: "myapp",
host: "testhost"
})) as { content: Array<{ text: string }> };
expect(composeService.composeRestart).toHaveBeenCalledWith(
expect.objectContaining({ name: "testhost" }),
"myapp"
);
expect(result.content).toBeDefined();
expect(result.content[0].text).toContain("Restarted project");
expect(result.content[0].text).toContain("myapp");
});
});
describe("compose action: utility operations", () => {
it("should get compose logs", async () => {
const result = (await toolHandler({
action: "compose",
subaction: "logs",
project: "myapp",
host: "testhost"
})) as { content: Array<{ text: string }> };
expect(composeService.composeLogs).toHaveBeenCalledWith(
expect.objectContaining({ name: "testhost" }),
"myapp",
expect.objectContaining({})
);
expect(result.content).toBeDefined();
expect(result.content[0].text).toContain("log line 1");
expect(result.content[0].text).toContain("log line 2");
});
it("should get compose logs for specific service", async () => {
const result = (await toolHandler({
action: "compose",
subaction: "logs",
project: "myapp",
host: "testhost",
service: "web"
})) as { content: Array<{ text: string }> };
expect(composeService.composeLogs).toHaveBeenCalledWith(
expect.objectContaining({ name: "testhost" }),
"myapp",
expect.objectContaining({ services: ["web"] })
);
expect(result.content).toBeDefined();
});
it("should get compose logs with line limit", async () => {
const result = (await toolHandler({
action: "compose",
subaction: "logs",
project: "myapp",
host: "testhost",
lines: 100
})) as { content: Array<{ text: string }> };
expect(composeService.composeLogs).toHaveBeenCalledWith(
expect.objectContaining({ name: "testhost" }),
"myapp",
expect.objectContaining({ tail: 100 })
);
expect(result.content).toBeDefined();
});
it("should build compose project", async () => {
const result = (await toolHandler({
action: "compose",
subaction: "build",
project: "myapp",
host: "testhost"
})) as { content: Array<{ text: string }> };
expect(composeService.composeBuild).toHaveBeenCalledWith(
expect.objectContaining({ name: "testhost" }),
"myapp",
expect.objectContaining({})
);
expect(result.content).toBeDefined();
expect(result.content[0].text).toContain("Built images");
expect(result.content[0].text).toContain("myapp");
});
it("should build compose project with no-cache", async () => {
const result = (await toolHandler({
action: "compose",
subaction: "build",
project: "myapp",
host: "testhost",
no_cache: true
})) as { content: Array<{ text: string }> };
expect(composeService.composeBuild).toHaveBeenCalledWith(
expect.objectContaining({ name: "testhost" }),
"myapp",
expect.objectContaining({ noCache: true })
);
expect(result.content).toBeDefined();
});
it("should build specific service in compose project", async () => {
const result = (await toolHandler({
action: "compose",
subaction: "build",
project: "myapp",
host: "testhost",
service: "web"
})) as { content: Array<{ text: string }> };
expect(composeService.composeBuild).toHaveBeenCalledWith(
expect.objectContaining({ name: "testhost" }),
"myapp",
expect.objectContaining({ service: "web" })
);
expect(result.content[0].text).toContain("web");
});
it("should pull compose images", async () => {
const result = (await toolHandler({
action: "compose",
subaction: "pull",
project: "myapp",
host: "testhost"
})) as { content: Array<{ text: string }> };
expect(composeService.composePull).toHaveBeenCalledWith(
expect.objectContaining({ name: "testhost" }),
"myapp",
expect.objectContaining({})
);
expect(result.content).toBeDefined();
expect(result.content[0].text).toContain("Pulled images");
expect(result.content[0].text).toContain("myapp");
});
it("should pull images for specific service", async () => {
const result = (await toolHandler({
action: "compose",
subaction: "pull",
project: "myapp",
host: "testhost",
service: "web"
})) as { content: Array<{ text: string }> };
expect(composeService.composePull).toHaveBeenCalledWith(
expect.objectContaining({ name: "testhost" }),
"myapp",
expect.objectContaining({ service: "web" })
);
expect(result.content[0].text).toContain("web");
});
it("should recreate compose containers", async () => {
const result = (await toolHandler({
action: "compose",
subaction: "recreate",
project: "myapp",
host: "testhost"
})) as { content: Array<{ text: string }> };
expect(composeService.composeRecreate).toHaveBeenCalledWith(
expect.objectContaining({ name: "testhost" }),
"myapp",
expect.objectContaining({})
);
expect(result.content).toBeDefined();
expect(result.content[0].text).toContain("Recreated project");
expect(result.content[0].text).toContain("myapp");
});
it("should recreate specific service in compose project", async () => {
const result = (await toolHandler({
action: "compose",
subaction: "recreate",
project: "myapp",
host: "testhost",
service: "web"
})) as { content: Array<{ text: string }> };
expect(composeService.composeRecreate).toHaveBeenCalledWith(
expect.objectContaining({ name: "testhost" }),
"myapp",
expect.objectContaining({ service: "web" })
);
expect(result.content[0].text).toContain("web");
});
});
});
describe("host actions", () => {
it("should handle host status request", async () => {
const result = await toolHandler({
action: "host",
subaction: "status"
});
expect(result).toBeDefined();
});
describe("host action: resources", () => {
it("should get host resources", async () => {
const sshService = await import("../services/ssh.js");
vi.spyOn(sshService, "getHostResources").mockResolvedValue({
hostname: "testhost.local",
uptime: "up 5 days",
loadAverage: [1.5, 1.2, 1.0],
cpu: {
cores: 8,
usagePercent: 25.5
},
memory: {
totalMB: 16384,
usedMB: 4096,
freeMB: 12288,
usagePercent: 25.0
},
disk: [
{
filesystem: "/dev/sda1",
mount: "/",
totalGB: 500,
usedGB: 120,
availGB: 380,
usagePercent: 24
}
]
});
const result = (await toolHandler({
action: "host",
subaction: "resources",
host: "testhost"
})) as { content: Array<{ text: string }> };
expect(sshService.getHostResources).toHaveBeenCalledWith(
expect.objectContaining({ name: "testhost" })
);
expect(result.content).toBeDefined();
expect(result.content[0].text).toContain("CPU");
expect(result.content[0].text).toContain("25.5");
expect(result.content[0].text).toContain("4096");
expect(result.content[0].text).toContain("16384");
});
});
});
describe("docker actions", () => {
describe("docker action: info", () => {
it("should get Docker system info", async () => {
const dockerService = await import("../services/docker.js");
vi.spyOn(dockerService, "getDockerInfo").mockResolvedValue({
dockerVersion: "24.0.5",
apiVersion: "1.43",
os: "linux",
arch: "amd64",
kernelVersion: "6.1.0",
cpus: 8,
memoryBytes: 16 * 1024 * 1024 * 1024,
storageDriver: "overlay2",
rootDir: "/var/lib/docker",
containersTotal: 15,
containersRunning: 10,
containersPaused: 0,
containersStopped: 5,
images: 25
});
const result = (await toolHandler({
action: "docker",
subaction: "info",
host: "testhost"
})) as { content: Array<{ text: string }> };
expect(dockerService.getDockerInfo).toHaveBeenCalledWith(
expect.objectContaining({ name: "testhost" })
);
expect(result.content).toBeDefined();
expect(result.content[0].text).toContain("24.0.5");
expect(result.content[0].text).toContain("1.43");
expect(result.content[0].text).toContain("linux");
});
});
describe("docker action: df", () => {
it("should get Docker disk usage", async () => {
const dockerService = await import("../services/docker.js");
vi.spyOn(dockerService, "getDockerDiskUsage").mockResolvedValue({
images: {
active: 10,
size: 5.2 * 1024 * 1024 * 1024,
reclaimable: 1 * 1024 * 1024 * 1024
},
containers: {
active: 5,
size: 1.5 * 1024 * 1024 * 1024,
reclaimable: 0.5 * 1024 * 1024 * 1024
},
volumes: {
active: 3,
size: 10 * 1024 * 1024 * 1024,
reclaimable: 2 * 1024 * 1024 * 1024
},
buildCache: { active: 2, size: 500 * 1024 * 1024, reclaimable: 250 * 1024 * 1024 }
});
const result = (await toolHandler({
action: "docker",
subaction: "df",
host: "testhost"
})) as { content: Array<{ text: string }> };
expect(dockerService.getDockerDiskUsage).toHaveBeenCalledWith(
expect.objectContaining({ name: "testhost" })
);
expect(result.content).toBeDefined();
// Should contain disk usage information
expect(result.content[0].text).toContain("Images");
expect(result.content[0].text).toContain("Containers");
expect(result.content[0].text).toContain("Volumes");
});
it("should return error for unknown host in df", async () => {
const result = (await toolHandler({
action: "docker",
subaction: "df",
host: "nonexistent-host"
})) as { content: Array<{ text: string }> };
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain("Host 'nonexistent-host' not found");
});
});
describe("docker action: prune", () => {
it("should require force flag for prune", async () => {
const result = (await toolHandler({
action: "docker",
subaction: "prune",
prune_target: "images"
// missing force: true
})) as { isError: boolean };
expect(result.isError).toBe(true);
});
it("should prune containers with force flag", async () => {
const dockerService = await import("../services/docker.js");
vi.spyOn(dockerService, "pruneDocker").mockResolvedValue([
{
type: "containers",
spaceReclaimed: 500 * 1024 * 1024, // 500MB
itemsDeleted: 5,
details: ["container1", "container2", "container3", "container4", "container5"]
}
]);
const result = (await toolHandler({
action: "docker",
subaction: "prune",
prune_target: "containers",
force: true,
host: "testhost"
})) as { content: Array<{ text: string }> };
expect(dockerService.pruneDocker).toHaveBeenCalledWith(
expect.objectContaining({ name: "testhost" }),
"containers"
);
expect(result.content).toBeDefined();
expect(result.content[0].text).toContain("containers");
});
it("should prune images with force flag", async () => {
const dockerService = await import("../services/docker.js");
vi.spyOn(dockerService, "pruneDocker").mockResolvedValue([
{
type: "images",
spaceReclaimed: 1.2 * 1024 * 1024 * 1024, // 1.2GB
itemsDeleted: 10,
details: []
}
]);
const result = (await toolHandler({
action: "docker",
subaction: "prune",
prune_target: "images",
force: true,
host: "testhost"
})) as { content: Array<{ text: string }> };
expect(dockerService.pruneDocker).toHaveBeenCalledWith(
expect.objectContaining({ name: "testhost" }),
"images"
);
expect(result.content).toBeDefined();
expect(result.content[0].text).toContain("images");
});
it("should prune volumes with force flag", async () => {
const dockerService = await import("../services/docker.js");
vi.spyOn(dockerService, "pruneDocker").mockResolvedValue([
{
type: "volumes",
spaceReclaimed: 2 * 1024 * 1024 * 1024, // 2GB
itemsDeleted: 3,
details: []
}
]);
const result = (await toolHandler({
action: "docker",
subaction: "prune",
prune_target: "volumes",
force: true,
host: "testhost"
})) as { content: Array<{ text: string }> };
expect(dockerService.pruneDocker).toHaveBeenCalledWith(
expect.objectContaining({ name: "testhost" }),
"volumes"
);
expect(result.content).toBeDefined();
expect(result.content[0].text).toContain("volumes");
});
it("should prune networks with force flag", async () => {
const dockerService = await import("../services/docker.js");
vi.spyOn(dockerService, "pruneDocker").mockResolvedValue([
{
type: "networks",
spaceReclaimed: 0, // Networks don't reclaim space
itemsDeleted: 2,
details: []
}
]);
const result = (await toolHandler({
action: "docker",
subaction: "prune",
prune_target: "networks",
force: true,
host: "testhost"
})) as { content: Array<{ text: string }> };
expect(dockerService.pruneDocker).toHaveBeenCalledWith(
expect.objectContaining({ name: "testhost" }),
"networks"
);
expect(result.content).toBeDefined();
expect(result.content[0].text).toContain("networks");
});
it("should prune everything (all) with force flag", async () => {
const dockerService = await import("../services/docker.js");
vi.spyOn(dockerService, "pruneDocker").mockResolvedValue([
{
type: "all",
spaceReclaimed: 3.7 * 1024 * 1024 * 1024, // 3.7GB total
itemsDeleted: 20,
details: []
}
]);
const result = (await toolHandler({
action: "docker",
subaction: "prune",
prune_target: "all",
force: true,
host: "testhost"
})) as { content: Array<{ text: string }> };
expect(dockerService.pruneDocker).toHaveBeenCalledWith(
expect.objectContaining({ name: "testhost" }),
"all"
);
expect(result.content).toBeDefined();
expect(result.content[0].text).toContain("all");
});
it("should return error for unknown host in prune", async () => {
const result = (await toolHandler({
action: "docker",
subaction: "prune",
prune_target: "images",
force: true,
host: "nonexistent-host"
})) as { content: Array<{ text: string }> };
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain("Host 'nonexistent-host' not found");
});
it("should handle errors during prune operation", async () => {
const dockerService = await import("../services/docker.js");
vi.spyOn(dockerService, "pruneDocker").mockRejectedValue(
new Error("Docker daemon not available")
);
const result = (await toolHandler({
action: "docker",
subaction: "prune",
prune_target: "images",
force: true,
host: "testhost"
})) as { content: Array<{ text: string }>; isError: boolean };
expect(result.content).toBeDefined();
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain("Failed to prune on testhost");
expect(result.content[0].text).toContain("Docker daemon not available");
});
});
describe("docker action: unknown subaction", () => {
it("should return error for unknown docker subaction", async () => {
const result = (await toolHandler({
action: "docker",
subaction: "invalid_action" as unknown
})) as { isError: boolean; content: Array<{ text: string }> };
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain("Invalid discriminator value");
});
});
});
describe("image actions", () => {
let dockerService: typeof import("../services/docker.js");
beforeEach(async () => {
dockerService = await import("../services/docker.js");
});
describe("image action: list", () => {
it("should list all images", async () => {
vi.spyOn(dockerService, "listImages").mockResolvedValue([
{
id: "sha256:abc123",
tags: ["nginx:latest"],
size: 142 * 1024 * 1024,
created: "2024-01-01T10:00:00Z",
containers: 1,
hostName: "testhost"
},
{
id: "sha256:def456",
tags: ["<none>:<none>"],
size: 1.2 * 1024 * 1024 * 1024,
created: "2024-01-02T10:00:00Z",
containers: 0,
hostName: "testhost"
}
]);
const result = (await toolHandler({
action: "image",
subaction: "list",
host: "testhost"
})) as { content: Array<{ text: string }> };
expect(dockerService.listImages).toHaveBeenCalledWith(
expect.any(Array),
expect.any(Object)
);
expect(result.content).toBeDefined();
expect(result.content[0].text).toContain("nginx");
expect(result.content[0].text).toContain("latest");
});
it("should list images with pagination", async () => {
vi.spyOn(dockerService, "listImages").mockResolvedValue([
{
id: "sha256:abc123",
tags: ["nginx:latest"],
size: 142 * 1024 * 1024,
created: "2024-01-01T10:00:00Z",
containers: 1,
hostName: "testhost"
},
{
id: "sha256:def456",
tags: ["<none>:<none>"],
size: 1.2 * 1024 * 1024 * 1024,
created: "2024-01-02T10:00:00Z",
containers: 0,
hostName: "testhost"
}
]);
const result = (await toolHandler({
action: "image",
subaction: "list",
host: "testhost",
offset: 1,
limit: 1,
response_format: "json"
})) as { content: Array<{ text: string }> };
expect(dockerService.listImages).toHaveBeenCalledWith(
expect.any(Array),
expect.any(Object)
);
expect(result.content).toBeDefined();
// With offset=1, limit=1, should only show second image
const output = JSON.parse(result.content[0].text);
expect(output.pagination.offset).toBe(1);
expect(output.pagination.count).toBe(1);
});
it("should list only dangling images", async () => {
vi.spyOn(dockerService, "listImages").mockResolvedValue([
{
id: "sha256:def456",
tags: ["<none>:<none>"],
size: 1.2 * 1024 * 1024 * 1024,
created: "2024-01-02T10:00:00Z",
containers: 0,
hostName: "testhost"
}
]);
const result = (await toolHandler({
action: "image",
subaction: "list",
host: "testhost",
dangling_only: true
})) as { content: Array<{ text: string }> };
expect(dockerService.listImages).toHaveBeenCalledWith(
expect.any(Array),
expect.objectContaining({ danglingOnly: true })
);
expect(result.content).toBeDefined();
});
});
describe("image action: pull", () => {
it("should pull image by name", async () => {
const dockerService = await import("../services/docker.js");
const result = (await toolHandler({
action: "image",
subaction: "pull",
image: "nginx:alpine",
host: "testhost"
})) as { content: Array<{ text: string }> };
expect(dockerService.pullImage).toHaveBeenCalledWith(
"nginx:alpine",
expect.objectContaining({ name: "testhost" })
);
expect(result.content).toBeDefined();
expect(result.content[0].text).toContain("pulled image");
expect(result.content[0].text).toContain("nginx:alpine");
});
it("should return error for unknown host in pull", async () => {
const result = (await toolHandler({
action: "image",
subaction: "pull",
image: "nginx:alpine",
host: "nonexistent-host"
})) as { content: Array<{ text: string }> };
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain("Host 'nonexistent-host' not found");
});
});
describe("image action: build", () => {
it("should build image from Dockerfile path", async () => {
const dockerService = await import("../services/docker.js");
const result = (await toolHandler({
action: "image",
subaction: "build",
context: "/app",
tag: "myapp:v1",
host: "testhost"
})) as { content: Array<{ text: string }> };
expect(dockerService.buildImage).toHaveBeenCalledWith(
expect.objectContaining({ name: "testhost" }),
expect.objectContaining({
context: "/app",
tag: "myapp:v1"
})
);
expect(result.content).toBeDefined();
expect(result.content[0].text).toContain("built image");
expect(result.content[0].text).toContain("myapp:v1");
});
it("should return error for unknown host in build", async () => {
const result = (await toolHandler({
action: "image",
subaction: "build",
context: "/app",
tag: "myapp:v1",
host: "nonexistent-host"
})) as { content: Array<{ text: string }> };
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain("Host 'nonexistent-host' not found");
});
});
describe("image action: remove", () => {
it("should remove image by ID", async () => {
const dockerService = await import("../services/docker.js");
const result = (await toolHandler({
action: "image",
subaction: "remove",
image: "sha256:abc123",
host: "testhost"
})) as { content: Array<{ text: string }> };
expect(dockerService.removeImage).toHaveBeenCalledWith(
"sha256:abc123",
expect.objectContaining({ name: "testhost" }),
expect.any(Object)
);
expect(result.content).toBeDefined();
expect(result.content[0].text).toContain("removed image");
expect(result.content[0].text).toContain("sha256:abc123");
});
it("should return error for unknown host in remove", async () => {
const result = (await toolHandler({
action: "image",
subaction: "remove",
image: "sha256:abc123",
host: "nonexistent-host"
})) as { content: Array<{ text: string }> };
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain("Host 'nonexistent-host' not found");
});
});
describe("image action: unknown subaction", () => {
it("should return error for unknown image subaction", async () => {
const result = (await toolHandler({
action: "image",
subaction: "invalid_action" as unknown,
host: "testhost"
})) as { isError: boolean; content: Array<{ text: string }> };
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain("Invalid discriminator value");
});
});
});
describe("response format", () => {
it("should return markdown by default", async () => {
const result = (await toolHandler({
action: "host",
subaction: "status"
})) as { content: Array<{ type: string; text: string }> };
expect(result.content).toBeDefined();
expect(result.content[0].type).toBe("text");
});
it("should return JSON when response_format is json", async () => {
const result = (await toolHandler({
action: "host",
subaction: "status",
response_format: "json"
})) as { content: Array<{ type: string; text: string }> };
expect(result.content).toBeDefined();
// JSON format should be parseable
expect(() => JSON.parse(result.content[0].text)).not.toThrow();
});
});
describe("schema validation", () => {
it("should reject unknown action", async () => {
const result = (await toolHandler({
action: "unknown",
subaction: "list"
})) as { isError: boolean };
expect(result.isError).toBe(true);
});
});
});
describe("Container stats collection performance", () => {
beforeEach(async () => {
// Mock getContainerStats to simulate 500ms delay
const dockerService = await import("../services/docker.js");
vi.spyOn(dockerService, "getContainerStats").mockImplementation(async (id, _host) => {
await new Promise((resolve) => setTimeout(resolve, 500));
return {
containerId: id,
containerName: `container-${id}`,
cpuPercent: 10.5,
memoryUsage: 1024 * 1024 * 100,
memoryLimit: 1024 * 1024 * 500,
memoryPercent: 20.0,
networkRx: 1024,
networkTx: 2048,
blockRead: 512,
blockWrite: 256
};
});
// Mock listContainers to return 5 containers (reduced for faster testing)
vi.spyOn(dockerService, "listContainers").mockResolvedValue(
Array.from({ length: 5 }, (_, i) => ({
id: `container-${i}`,
name: `test-${i}`,
image: "test:latest",
state: "running" as const,
status: "Up 1 hour",
created: new Date().toISOString(),
ports: [],
labels: {},
hostName: "test-host"
}))
);
// Mock loadHostConfigs to return 2 test hosts
vi.spyOn(dockerService, "loadHostConfigs").mockReturnValue([
{
name: "host1",
host: "192.168.1.10",
protocol: "http" as const,
port: 2375
},
{
name: "host2",
host: "192.168.1.11",
protocol: "http" as const,
port: 2375
}
]);
});
it("should measure baseline performance (was sequential, now parallel)", async () => {
const { registerUnifiedTool } = await import("./unified.js");
const mockServer = {
registerTool: vi.fn()
} as unknown as McpServer;
const container = createMockContainer();
registerUnifiedTool(mockServer, container);
const handler = (mockServer.registerTool as ReturnType<typeof vi.fn>).mock.calls[0][2];
const startTime = Date.now();
const result = (await handler({
action: "container",
subaction: "stats",
response_format: "json"
})) as { content: Array<{ text: string }> };
const duration = Date.now() - startTime;
expect(result.content).toBeDefined();
expect(result.content[0].text).toContain("stats");
// Before optimization: 2 hosts × 5 containers × 500ms = 5000ms sequential
// After optimization: Parallel execution ~500ms
expect(duration).toBeLessThan(1000);
console.log(`Baseline performance: ${duration}ms (parallel optimized)`);
}, 10000);
it("should collect stats in parallel across hosts and containers", async () => {
const { registerUnifiedTool } = await import("./unified.js");
const mockServer = {
registerTool: vi.fn()
} as unknown as McpServer;
const container = createMockContainer();
registerUnifiedTool(mockServer, container);
const handler = (mockServer.registerTool as ReturnType<typeof vi.fn>).mock.calls[0][2];
const startTime = Date.now();
const result = (await handler({
action: "container",
subaction: "stats",
response_format: "json"
})) as { content: Array<{ text: string }> };
const duration = Date.now() - startTime;
expect(result.content).toBeDefined();
const output = JSON.parse(result.content[0].text);
expect(output.stats).toHaveLength(10); // 2 hosts × 5 containers
// Parallel: max(500ms) + overhead ≈ 600-800ms
expect(duration).toBeLessThan(1000);
console.log(`Parallel optimized: ${duration}ms`);
console.log(`Speedup: ${(5000 / duration).toFixed(1)}x`);
}, 10000);
it("should handle partial failures gracefully", async () => {
const dockerService = await import("../services/docker.js");
// Mock some stats calls to fail
vi.spyOn(dockerService, "getContainerStats").mockImplementation(async (id, _host) => {
if (id === "container-2") {
throw new Error("Container not responding");
}
await new Promise((resolve) => setTimeout(resolve, 100));
return {
containerId: id,
containerName: `container-${id}`,
cpuPercent: 10.5,
memoryUsage: 1024 * 1024 * 100,
memoryLimit: 1024 * 1024 * 500,
memoryPercent: 20.0,
networkRx: 1024,
networkTx: 2048,
blockRead: 512,
blockWrite: 256
};
});
const { registerUnifiedTool } = await import("./unified.js");
const mockServer = {
registerTool: vi.fn()
} as unknown as McpServer;
const container = createMockContainer();
registerUnifiedTool(mockServer, container);
const handler = (mockServer.registerTool as ReturnType<typeof vi.fn>).mock.calls[0][2];
const result = (await handler({
action: "container",
subaction: "stats",
response_format: "json"
})) as { content: Array<{ text: string }> };
expect(result.content).toBeDefined();
const output = JSON.parse(result.content[0].text);
// Should have stats for 8 containers (10 total - 2 that failed)
expect(output.stats.length).toBeGreaterThan(0);
expect(output.stats.length).toBeLessThan(10);
});
});
describe("Error handling: unknown subactions", () => {
let mockServer: McpServer;
let toolHandler: (params: unknown) => Promise<unknown>;
beforeEach(async () => {
vi.clearAllMocks();
const container = createMockContainer();
const registeredTools = new Map<string, (params: unknown) => Promise<unknown>>();
mockServer = {
registerTool: vi.fn((name, _config, handler) => {
registeredTools.set(name, handler);
})
} as unknown as McpServer;
registerUnifiedTool(mockServer, container);
const handler = registeredTools.get("homelab");
if (!handler) throw new Error("Tool handler not registered");
toolHandler = handler;
});
it("should handle unknown container subaction", async () => {
const result = (await toolHandler({
action: "container",
subaction: "invalid_action",
container_id: "test",
host: "testhost"
})) as { isError: boolean; content: Array<{ text: string }> };
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain("Error");
});
it("should handle unknown image subaction", async () => {
const result = (await toolHandler({
action: "image",
subaction: "invalid_action",
host: "testhost"
})) as { isError: boolean; content: Array<{ text: string }> };
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain("Error");
});
it("should handle unknown compose subaction", async () => {
const result = (await toolHandler({
action: "compose",
subaction: "invalid_action",
project: "myapp",
host: "testhost"
})) as { isError: boolean; content: Array<{ text: string }> };
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain("Error");
});
it("should handle unknown docker subaction", async () => {
const result = (await toolHandler({
action: "docker",
subaction: "invalid_action",
host: "testhost"
})) as { isError: boolean; content: Array<{ text: string }> };
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain("Error");
});
it("should handle unknown host subaction", async () => {
const result = (await toolHandler({
action: "host",
subaction: "invalid_action",
host: "testhost"
})) as { isError: boolean; content: Array<{ text: string }> };
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain("Error");
});
});
describe("Error handling: invalid hosts", () => {
let mockServer: McpServer;
let toolHandler: (params: unknown) => Promise<unknown>;
let dockerService: typeof import("../services/docker.js");
beforeEach(async () => {
vi.clearAllMocks();
dockerService = await import("../services/docker.js");
const container = createMockContainer();
const registeredTools = new Map<string, (params: unknown) => Promise<unknown>>();
mockServer = {
registerTool: vi.fn((name, _config, handler) => {
registeredTools.set(name, handler);
})
} as unknown as McpServer;
registerUnifiedTool(mockServer, container);
const handler = registeredTools.get("homelab");
if (!handler) throw new Error("Tool handler not registered");
toolHandler = handler;
// Mock loadHostConfigs to return empty array (no hosts found)
vi.spyOn(dockerService, "loadHostConfigs").mockReturnValue([]);
});
it("should handle container action with invalid host", async () => {
const result = (await toolHandler({
action: "container",
subaction: "list",
host: "nonexistent"
})) as { isError: boolean; content: Array<{ text: string }> };
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain("Host");
});
it("should handle image action with invalid host", async () => {
const result = (await toolHandler({
action: "image",
subaction: "list",
host: "nonexistent"
})) as { isError: boolean; content: Array<{ text: string }> };
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain("Host");
});
it("should handle compose action with invalid host", async () => {
const result = (await toolHandler({
action: "compose",
subaction: "list",
host: "nonexistent"
})) as { isError: boolean; content: Array<{ text: string }> };
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain("Host");
});
it("should handle docker action with invalid host", async () => {
const result = (await toolHandler({
action: "docker",
subaction: "info",
host: "nonexistent"
})) as { isError: boolean; content: Array<{ text: string }> };
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain("Host");
});
it("should handle host action with invalid host", async () => {
const result = (await toolHandler({
action: "host",
subaction: "resources",
host: "nonexistent"
})) as { isError: boolean; content: Array<{ text: string }> };
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain("Host");
});
});
describe("Edge cases: empty results", () => {
let mockServer: McpServer;
let toolHandler: (params: unknown) => Promise<unknown>;
let dockerService: typeof import("../services/docker.js");
let composeService: typeof import("../services/compose.js");
beforeEach(async () => {
vi.clearAllMocks();
dockerService = await import("../services/docker.js");
composeService = await import("../services/compose.js");
const container = createMockContainer();
const registeredTools = new Map<string, (params: unknown) => Promise<unknown>>();
mockServer = {
registerTool: vi.fn((name, _config, handler) => {
registeredTools.set(name, handler);
})
} as unknown as McpServer;
registerUnifiedTool(mockServer, container);
const handler = registeredTools.get("homelab");
if (!handler) throw new Error("Tool handler not registered");
toolHandler = handler;
// Restore default host config
vi.spyOn(dockerService, "loadHostConfigs").mockReturnValue([
{ name: "testhost", host: "localhost", port: 2375, protocol: "http" }
]);
});
it("should handle container search with no results", async () => {
// Mock findContainerHost to return null (container not found on any host)
vi.spyOn(dockerService, "findContainerHost").mockResolvedValue(null);
const result = (await toolHandler({
action: "container",
subaction: "stats",
container_id: "nonexistent"
// No host specified - triggers multi-host search
})) as { isError: boolean; content: Array<{ text: string }> };
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain("not found");
});
it("should handle empty compose project list", async () => {
vi.spyOn(composeService, "listComposeProjects").mockResolvedValue([]);
const result = (await toolHandler({
action: "compose",
subaction: "list",
host: "testhost"
})) as { content: Array<{ text: string }> };
expect(result.content).toBeDefined();
expect(result.content[0].text).toContain("No compose projects");
});
it("should handle pagination with offset beyond results", async () => {
vi.spyOn(dockerService, "listContainers").mockResolvedValue([
{
id: "abc123",
name: "test-container",
image: "nginx:latest",
state: "running",
status: "Up 1 hour",
created: "2024-01-01T10:00:00Z",
ports: [],
labels: {},
hostName: "testhost"
}
]);
const result = (await toolHandler({
action: "container",
subaction: "list",
host: "testhost",
offset: 1000, // Far beyond actual results
limit: 10,
response_format: "json"
})) as { content: Array<{ text: string }> };
expect(result.content).toBeDefined();
const output = JSON.parse(result.content[0].text);
expect(output.offset).toBe(1000);
expect(output.count).toBe(0);
expect(output.containers).toHaveLength(0);
});
it("should handle empty logs output", async () => {
vi.spyOn(dockerService, "getContainerLogs").mockResolvedValue([]);
const result = (await toolHandler({
action: "container",
subaction: "logs",
container_id: "my-container",
host: "testhost"
})) as { content: Array<{ text: string }> };
expect(result.content).toBeDefined();
expect(result.content[0].text).toContain("No logs");
});
});
describe("scout action integration", () => {
let mockServer: McpServer;
let toolHandler: (params: unknown) => Promise<unknown>;
let container: ServiceContainer;
beforeEach(async () => {
vi.clearAllMocks();
container = createMockContainer();
const registeredTools = new Map<string, (params: unknown) => Promise<unknown>>();
mockServer = {
registerTool: vi.fn((name, _config, handler) => {
registeredTools.set(name, handler);
})
} as unknown as McpServer;
registerUnifiedTool(mockServer, container);
const handler = registeredTools.get("homelab");
if (!handler) throw new Error("Tool handler not registered");
toolHandler = handler;
});
describe("scout action: read", () => {
it("should read file from configured host", async () => {
(mockFileService.readFile as ReturnType<typeof vi.fn>).mockResolvedValue({
content: "test file content",
size: 17,
truncated: false
});
const result = (await toolHandler({
action: "scout",
subaction: "read",
host: "testhost",
path: "/etc/hosts"
})) as { isError?: boolean; content: Array<{ text: string }> };
expect(result.isError).toBeFalsy();
expect(result.content[0].text).toContain("test file content");
expect(mockFileService.readFile).toHaveBeenCalledWith(
expect.objectContaining({ name: "testhost" }),
"/etc/hosts",
expect.any(Number)
);
});
it("should indicate truncation when file exceeds limit", async () => {
(mockFileService.readFile as ReturnType<typeof vi.fn>).mockResolvedValue({
content: "truncated content...",
size: 1048576,
truncated: true
});
const result = (await toolHandler({
action: "scout",
subaction: "read",
host: "testhost",
path: "/var/log/large.log"
})) as { content: Array<{ text: string }> };
expect(result.content[0].text).toContain("truncated");
});
});
describe("scout action: list", () => {
it("should list directory contents", async () => {
(mockFileService.listDirectory as ReturnType<typeof vi.fn>).mockResolvedValue(
"total 4\ndrwxr-xr-x 2 root root 4096 Jan 1 00:00 ."
);
const result = (await toolHandler({
action: "scout",
subaction: "list",
host: "testhost",
path: "/var/log"
})) as { isError?: boolean; content: Array<{ text: string }> };
expect(result.isError).toBeFalsy();
expect(result.content[0].text).toContain("total 4");
expect(mockFileService.listDirectory).toHaveBeenCalledWith(
expect.objectContaining({ name: "testhost" }),
"/var/log",
false // default all=false
);
});
it("should list hidden files when all=true", async () => {
(mockFileService.listDirectory as ReturnType<typeof vi.fn>).mockResolvedValue(
"total 8\n.hidden_file"
);
await toolHandler({
action: "scout",
subaction: "list",
host: "testhost",
path: "/home/user",
all: true
});
expect(mockFileService.listDirectory).toHaveBeenCalledWith(
expect.objectContaining({ name: "testhost" }),
"/home/user",
true
);
});
});
describe("scout action: tree", () => {
it("should show directory tree", async () => {
(mockFileService.treeDirectory as ReturnType<typeof vi.fn>).mockResolvedValue(
".\n├── dir1\n└── file.txt"
);
const result = (await toolHandler({
action: "scout",
subaction: "tree",
host: "testhost",
path: "/home",
depth: 3
})) as { isError?: boolean; content: Array<{ text: string }> };
expect(result.isError).toBeFalsy();
expect(result.content[0].text).toContain("├── dir1");
expect(mockFileService.treeDirectory).toHaveBeenCalledWith(
expect.objectContaining({ name: "testhost" }),
"/home",
3
);
});
});
describe("scout action: exec", () => {
it("should execute allowed command", async () => {
(mockFileService.executeCommand as ReturnType<typeof vi.fn>).mockResolvedValue({
stdout: "file1.txt\nfile2.txt",
exitCode: 0
});
const result = (await toolHandler({
action: "scout",
subaction: "exec",
host: "testhost",
path: "/tmp",
command: "ls"
})) as { isError?: boolean; content: Array<{ text: string }> };
expect(result.isError).toBeFalsy();
expect(result.content[0].text).toContain("file1.txt");
expect(mockFileService.executeCommand).toHaveBeenCalledWith(
expect.objectContaining({ name: "testhost" }),
"/tmp",
"ls",
expect.any(Number)
);
});
});
describe("scout action: find", () => {
it("should find files by pattern", async () => {
(mockFileService.findFiles as ReturnType<typeof vi.fn>).mockResolvedValue(
"/var/log/syslog\n/var/log/auth.log"
);
const result = (await toolHandler({
action: "scout",
subaction: "find",
host: "testhost",
path: "/var",
pattern: "*.log"
})) as { isError?: boolean; content: Array<{ text: string }> };
expect(result.isError).toBeFalsy();
expect(result.content[0].text).toContain("/var/log/syslog");
expect(mockFileService.findFiles).toHaveBeenCalledWith(
expect.objectContaining({ name: "testhost" }),
"/var",
"*.log",
expect.objectContaining({})
);
});
it("should find files with type filter", async () => {
(mockFileService.findFiles as ReturnType<typeof vi.fn>).mockResolvedValue(
"/var/log\n/var/cache"
);
await toolHandler({
action: "scout",
subaction: "find",
host: "testhost",
path: "/var",
pattern: "*",
type: "d"
});
expect(mockFileService.findFiles).toHaveBeenCalledWith(
expect.objectContaining({ name: "testhost" }),
"/var",
"*",
expect.objectContaining({ type: "d" })
);
});
});
describe("scout action: transfer", () => {
it("should transfer file between hosts", async () => {
(mockFileService.transferFile as ReturnType<typeof vi.fn>).mockResolvedValue({
bytesTransferred: 1024
});
const result = (await toolHandler({
action: "scout",
subaction: "transfer",
source_host: "testhost",
source_path: "/tmp/file.txt",
target_host: "testhost",
target_path: "/backup/file.txt"
})) as { isError?: boolean; content: Array<{ text: string }> };
expect(result.isError).toBeFalsy();
expect(result.content[0].text).toContain("1.0 KB"); // 1024 bytes formatted as KB
expect(mockFileService.transferFile).toHaveBeenCalledWith(
expect.objectContaining({ name: "testhost" }),
"/tmp/file.txt",
expect.objectContaining({ name: "testhost" }),
"/backup/file.txt"
);
});
it("should show warning for system path target", async () => {
(mockFileService.transferFile as ReturnType<typeof vi.fn>).mockResolvedValue({
bytesTransferred: 512,
warning: "Warning: target is a system path (/etc/config)"
});
const result = (await toolHandler({
action: "scout",
subaction: "transfer",
source_host: "testhost",
source_path: "/tmp/config",
target_host: "testhost",
target_path: "/etc/config"
})) as { content: Array<{ text: string }> };
expect(result.content[0].text).toContain("Warning");
});
});
describe("scout action: diff", () => {
it("should diff files on same host", async () => {
(mockFileService.diffFiles as ReturnType<typeof vi.fn>).mockResolvedValue(
"--- /etc/config.old\n+++ /etc/config.new\n@@ -1,3 +1,3 @@\n-old\n+new"
);
const result = (await toolHandler({
action: "scout",
subaction: "diff",
host1: "testhost",
path1: "/etc/config.old",
host2: "testhost",
path2: "/etc/config.new"
})) as { isError?: boolean; content: Array<{ text: string }> };
expect(result.isError).toBeFalsy();
expect(result.content[0].text).toContain("-old");
expect(result.content[0].text).toContain("+new");
expect(mockFileService.diffFiles).toHaveBeenCalledWith(
expect.objectContaining({ name: "testhost" }),
"/etc/config.old",
expect.objectContaining({ name: "testhost" }),
"/etc/config.new",
3 // default context lines
);
});
it("should report identical files", async () => {
(mockFileService.diffFiles as ReturnType<typeof vi.fn>).mockResolvedValue(
"(files are identical)"
);
const result = (await toolHandler({
action: "scout",
subaction: "diff",
host1: "testhost",
path1: "/etc/hosts",
host2: "testhost",
path2: "/etc/hosts.backup"
})) as { content: Array<{ text: string }> };
expect(result.content[0].text).toContain("identical");
});
});
describe("scout security validation", () => {
it("should reject path traversal attempts", async () => {
// The FileService validates paths before calling SSH
(mockFileService.readFile as ReturnType<typeof vi.fn>).mockRejectedValue(
new Error("path contains path traversal")
);
const result = (await toolHandler({
action: "scout",
subaction: "read",
host: "testhost",
path: "/../etc/passwd"
})) as { isError: boolean; content: Array<{ text: string }> };
expect(result.isError).toBe(true);
expect(result.content[0].text).toMatch(/traversal|invalid|error/i);
});
it("should reject blocked commands", async () => {
// The FileService validates commands before executing
(mockFileService.executeCommand as ReturnType<typeof vi.fn>).mockRejectedValue(
new Error("Command 'rm' not in allowed list")
);
const result = (await toolHandler({
action: "scout",
subaction: "exec",
host: "testhost",
path: "/tmp",
command: "rm -rf /"
})) as { isError: boolean; content: Array<{ text: string }> };
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain("not in allowed list");
});
});
describe("scout error handling", () => {
it("should return error for unknown host", async () => {
const result = (await toolHandler({
action: "scout",
subaction: "read",
host: "nonexistent",
path: "/etc/hosts"
})) as { isError: boolean; content: Array<{ text: string }> };
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain("Host 'nonexistent' not found");
});
it("should handle SSH connection errors", async () => {
(mockFileService.readFile as ReturnType<typeof vi.fn>).mockRejectedValue(
new Error("SSH connection refused")
);
const result = (await toolHandler({
action: "scout",
subaction: "read",
host: "testhost",
path: "/etc/hosts"
})) as { isError: boolean; content: Array<{ text: string }> };
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain("SSH connection refused");
});
});
});