/** * Dockhand adapter — uses Dockhand's own aggregating REST API (not the raw * Docker Engine API on each host directly). Requires config: url, token * * Dockhand is a multi-host Docker manager; "environments" are the individual * Docker hosts/agents it's connected to, and containers are listed/controlled * per environment. * * API reference verified against Dockhand's published OpenAPI spec * (https://github.com/strausmann/mcp-dockhand/blob/main/docs/dockhand-openapi.json). */ import { withDiagLogging } from "../../services/diagLog.js"; export interface DockhandConfig { url: string; token: string; } export interface DockhandEnvironment { id: number; name: string; connectionType: string; } export interface DockhandContainer { id: string; name: string; image: string; state: string; // "running" | "exited" | "paused" | "restarting" | "created" | "dead" status: string; // human string, e.g. "Up 2 hours (healthy)" environmentId: number; environmentName: string; } export interface DockhandAdapter { ping(): Promise<{ ok: boolean; latencyMs?: number; error?: string }>; listContainers(): Promise; startContainer(environmentId: number, containerId: string): Promise; stopContainer(environmentId: number, containerId: string): Promise; restartContainer(environmentId: number, containerId: string): Promise; } export function createDockhandAdapter(config: DockhandConfig): DockhandAdapter { function base() { return config.url.replace(/\/$/, ""); } function headers() { return { Authorization: `Bearer ${config.token}`, Accept: "application/json", "Content-Type": "application/json", }; } async function api(method: string, path: string): Promise { const res = await fetch(`${base()}${path}`, { method, headers: headers() }); const text = await res.text(); let data: any = null; try { data = text ? JSON.parse(text) : null; } catch { // non-JSON error page } if (!res.ok) { throw new Error(data?.message || data?.error || `Dockhand API error: HTTP ${res.status}`); } return data; } async function listEnvironments(): Promise { const data = await api("GET", "/api/environments"); return (Array.isArray(data) ? data : []).map((e: any) => ({ id: e.id, name: e.name, connectionType: e.connectionType, })); } async function listContainers(): Promise { const environments = await listEnvironments(); const perEnv = await Promise.all( environments.map(async (env) => { try { const data = await api("GET", `/api/containers?env=${env.id}&all=true`); return (Array.isArray(data) ? data : []).map((c: any) => ({ id: c.id, name: c.name, image: c.image, state: c.state, status: c.status, environmentId: env.id, environmentName: env.name, })); } catch { // one unreachable host shouldn't take down the whole dashboard view return []; } }), ); return perEnv.flat(); } async function startContainer(environmentId: number, containerId: string): Promise { await api("POST", `/api/containers/${encodeURIComponent(containerId)}/start?env=${environmentId}`); } async function stopContainer(environmentId: number, containerId: string): Promise { await api("POST", `/api/containers/${encodeURIComponent(containerId)}/stop?env=${environmentId}`); } async function restartContainer(environmentId: number, containerId: string): Promise { await api("POST", `/api/containers/${encodeURIComponent(containerId)}/restart?env=${environmentId}`); } async function ping(): Promise<{ ok: boolean; latencyMs?: number; error?: string }> { const start = Date.now(); try { await api("GET", "/api/environments"); return { ok: true, latencyMs: Date.now() - start }; } catch (err) { return { ok: false, error: err instanceof Error ? err.message : String(err) }; } } return withDiagLogging("dockhand", { ping, listContainers, startContainer, stopContainer, restartContainer }); }