/** * DashCaddy MCP (Model Context Protocol) Server * * Makes DashCaddy controllable by ANY AI agent — Hermes, Claude, GPT, etc. * The AI agent connects to this server and can: * - List and manage services/containers * - Deploy apps from the catalog * - Manage DNS records and Caddyfile routes * - Run diagnostics * - Create backups and restore * - Check system health * * Protocol: JSON-RPC 2.0 over stdio * Spec: https://modelcontextprotocol.io * * Usage: * node mcp-server.js * * In an AI agent config (e.g. Claude Desktop): * { * "mcpServers": { * "dashcaddy": { * "command": "node", * "args": ["/path/to/mcp-server.js"], * "env": { * "DASHCADDY_URL": "http://localhost:3001", * "DASHCADDY_API_KEY": "dk_..." * } * } * } * } */ const readline = require('readline'); // ─── Configuration ────────────────────────────────────────────────────────── const BASE_URL = process.env.DASHCADDY_URL || 'http://localhost:3001'; const API_KEY = process.env.DASHCADDY_API_KEY || ''; const MCP_VERSION = '2024-11-05'; // ─── Tool Definitions ─────────────────────────────────────────────────────── const TOOLS = [ // ── Services ── { name: 'dashcaddy_list_services', description: 'List all services on the DashCaddy dashboard. Returns service ID, name, status (up/down), URL, and health.', inputSchema: { type: 'object', properties: {} }, }, { name: 'dashcaddy_get_service', description: 'Get details for a specific service by ID. Includes health history, credentials, and configuration.', inputSchema: { type: 'object', properties: { serviceId: { type: 'string', description: 'The service ID (e.g. "plex")' }, }, required: ['serviceId'], }, }, { name: 'dashcaddy_check_health', description: 'Check the health of all services or a specific service. Returns up/down status, response time, and HTTP status code.', inputSchema: { type: 'object', properties: { serviceId: { type: 'string', description: 'Optional: check only this service. Omit for all services.' }, }, }, }, // ── System ── { name: 'dashcaddy_system_health', description: 'Get overall system health summary. Returns status (healthy/degraded/unhealthy), service counts, memory, disk, and uptime. Great for "is everything OK?" queries.', inputSchema: { type: 'object', properties: {} }, }, { name: 'dashcaddy_system_metrics', description: 'Get Prometheus-format metrics for system monitoring. Includes request counts, error rates, memory gauges.', inputSchema: { type: 'object', properties: {} }, }, // ── Containers ── { name: 'dashcaddy_list_containers', description: 'List all Docker containers (running and stopped). Returns container ID, name, image, status, and ports.', inputSchema: { type: 'object', properties: { all: { type: 'boolean', description: 'Include stopped containers (default: true)' }, }, }, }, { name: 'dashcaddy_container_action', description: 'Start, stop, restart, or remove a Docker container.', inputSchema: { type: 'object', properties: { containerId: { type: 'string', description: 'Container ID or name' }, action: { type: 'string', enum: ['start', 'stop', 'restart', 'remove'], description: 'Action to perform' }, }, required: ['containerId', 'action'], }, }, // ── Catalog & Discovery ── { name: 'dashcaddy_search_catalog', description: 'Search the app catalog for self-hostable applications. Use this when a user asks "can DashCaddy host X?" or "I want to self-host Y".', inputSchema: { type: 'object', properties: { query: { type: 'string', description: 'Search query (e.g. "media streaming", "password manager", "ad blocker")' }, category: { type: 'string', description: 'Filter by category (media, development, network, database, etc.)' }, }, }, }, { name: 'dashcaddy_discover_services', description: 'Auto-detect running Docker containers and suggest adding them to the dashboard. Returns discovered services with suggested configs.', inputSchema: { type: 'object', properties: {} }, }, // ── Deployment ── { name: 'dashcaddy_deploy_app', description: 'Deploy a self-hosted application from the catalog. This is the main "self-host X" action. Pulls the Docker image, creates the container, generates a Caddyfile reverse proxy route, and adds the service to the dashboard. Returns the URL the user can access.', inputSchema: { type: 'object', properties: { templateId: { type: 'string', description: 'App template ID from the catalog (e.g. "plex", "gitea", "nextcloud")' }, subdomain: { type: 'string', description: 'Subdomain for the service (e.g. "plex" → plex.example.com)' }, port: { type: 'number', description: 'Override the default port' }, }, required: ['templateId'], }, }, { name: 'dashcaddy_wizard_recommend', description: 'Get service recommendations based on what the user wants to self-host. Use this when a user describes a goal (e.g. "I want to stream movies" → recommends Plex, Sonarr, Radarr).', inputSchema: { type: 'object', properties: { categories: { type: 'array', items: { type: 'string' }, description: 'Categories: media-streaming, file-sync, home-network, smart-home, development, monitoring', }, hardwareProfile: { type: 'string', enum: ['minimal', 'medium', 'powerful'], description: 'Hardware capability (default: medium)' }, }, required: ['categories'], }, }, // ── DNS & Proxy ── { name: 'dashcaddy_list_dns', description: 'List DNS records. Useful for "what domains point to this server?"', inputSchema: { type: 'object', properties: { zone: { type: 'string', description: 'DNS zone to query (optional)' }, }, }, }, { name: 'dashcaddy_generate_caddyfile', description: 'Generate a Caddyfile reverse proxy block from structured config. Useful for setting up custom reverse proxy rules.', inputSchema: { type: 'object', properties: { domain: { type: 'string', description: 'Domain name (e.g. "app.example.com")' }, upstream: { type: 'string', description: 'Upstream address (e.g. "localhost:8080")' }, websocket: { type: 'boolean', description: 'Enable WebSocket support' }, cors: { type: 'boolean', description: 'Enable CORS headers' }, auth: { type: 'boolean', description: 'Enable DashCaddy SSO auth gate' }, }, required: ['domain', 'upstream'], }, }, // ── Diagnostics ── { name: 'dashcaddy_diagnose', description: 'Run diagnostics on a service or the entire system. Checks container logs, resource usage, network connectivity, and health endpoints. Returns structured findings with severity levels.', inputSchema: { type: 'object', properties: { serviceId: { type: 'string', description: 'Service to diagnose (omit for system-wide)' }, depth: { type: 'string', enum: ['quick', 'standard', 'deep'], description: 'Diagnostic depth (default: standard)' }, }, }, }, // ── Backup & Recovery ── { name: 'dashcaddy_create_backup', description: 'Create a full system backup (services, config, credentials, Caddyfile, themes). Returns the backup data.', inputSchema: { type: 'object', properties: {} }, }, { name: 'dashcaddy_get_backup_status', description: 'Check the status of the last backup and restore operations.', inputSchema: { type: 'object', properties: {} }, }, // ── Fleet ── { name: 'dashcaddy_list_fleet', description: 'List all hosts in the DashCaddy fleet (for multi-server management).', inputSchema: { type: 'object', properties: {} }, }, ]; // ─── API Client ───────────────────────────────────────────────────────────── async function apiCall(method, path, body) { const url = `${BASE_URL}/api/v1${path}`; const headers = { 'Content-Type': 'application/json' }; if (API_KEY) headers['x-api-key'] = API_KEY; try { const response = await fetch(url, { method, headers, body: body ? JSON.stringify(body) : undefined, }); const text = await response.text(); let data; try { data = JSON.parse(text); } catch { data = { raw: text }; } if (!response.ok) { return { error: true, status: response.status, message: data.error || data.message || `HTTP ${response.status}`, code: data.code, }; } return data; } catch (err) { return { error: true, message: err.message, code: 'NETWORK_ERROR' }; } } // ─── Tool Handlers ────────────────────────────────────────────────────────── async function handleTool(name, args) { switch (name) { // ── Services ── case 'dashcaddy_list_services': { const data = await apiCall('GET', '/services'); if (data.error) return data; const services = data.services || data.data || []; return { count: services.length, services: services.map(s => ({ id: s.id, name: s.name, status: s.status || 'unknown', url: s.url, subdomain: s.subdomain, type: s.type, })), }; } case 'dashcaddy_get_service': { return apiCall('GET', `/services/${args.serviceId}`); } case 'dashcaddy_check_health': { if (args.serviceId) { return apiCall('GET', `/services/${args.serviceId}/health`); } return apiCall('GET', '/health/all'); } // ── System ── case 'dashcaddy_system_health': { // Public endpoint — no auth needed const response = await fetch(`${BASE_URL}/api/v1/system/health`); return response.json(); } case 'dashcaddy_system_metrics': { const response = await fetch(`${BASE_URL}/api/v1/metrics/prometheus`); return { metrics: await response.text() }; } // ── Containers ── case 'dashcaddy_list_containers': { const all = args.all !== false; return apiCall('GET', `/containers?all=${all}`); } case 'dashcaddy_container_action': { const { containerId, action } = args; const method = action === 'remove' ? 'DELETE' : 'POST'; return apiCall(method, `/containers/${containerId}/${action}`); } // ── Catalog & Discovery ── case 'dashcaddy_search_catalog': { let path = '/catalog'; if (args.query) { return apiCall('GET', `/catalog/search?q=${encodeURIComponent(args.query)}`); } if (args.category) path += `?category=${args.category}`; return apiCall('GET', path); } case 'dashcaddy_discover_services': { return apiCall('GET', '/discover'); } // ── Deployment ── case 'dashcaddy_deploy_app': { // Step 1: Get template details const template = await apiCall('GET', `/catalog/${args.templateId}`); if (template.error) return template; // Step 2: Generate Caddyfile route const port = args.port || template.ports?.[0] || 8080; const subdomain = args.subdomain || args.templateId; const caddy = await apiCall('POST', '/caddycode/generate', { domain: `${subdomain}.sami`, upstream: `localhost:${port}`, websocket: true, cors: true, }); // Step 3: Create service entry const service = await apiCall('POST', '/services', { id: subdomain, name: template.name, subdomain, domain: `${subdomain}.sami`, url: `https://${subdomain}.sami`, port, protocol: 'http', type: template.category || 'generic', }); return { deployed: !service.error, service: service.error ? null : service, caddyfile: caddy.error ? null : caddy.caddyfile, url: `https://${subdomain}.sami`, message: service.error ? `Deployment failed: ${service.message}` : `${template.name} deployed! Access it at https://${subdomain}.sami`, nextSteps: [ `Pull the Docker image: docker pull ${template.image || 'unknown'}`, `Run the container with port ${port} mapped`, `The Caddyfile route is configured — the URL should work once the container is running`, ], }; } case 'dashcaddy_wizard_recommend': { return apiCall('POST', '/wizard/recommend', { categories: args.categories, hardwareProfile: args.hardwareProfile || 'medium', }); } // ── DNS & Proxy ── case 'dashcaddy_list_dns': { let path = '/dns'; if (args.zone) path += `?zone=${args.zone}`; return apiCall('GET', path); } case 'dashcaddy_generate_caddyfile': { return apiCall('POST', '/caddycode/generate', { domain: args.domain, upstream: args.upstream, websocket: args.websocket, cors: args.cors, auth: args.auth, }); } // ── Diagnostics ── case 'dashcaddy_diagnose': { const findings = []; if (args.serviceId) { // Service-specific diagnosis const health = await apiCall('GET', `/services/${args.serviceId}/health`); if (health.error) { findings.push({ severity: 'critical', message: `Cannot reach service: ${health.message}` }); } else { findings.push({ severity: 'info', message: `Service ${args.serviceId} health: ${JSON.stringify(health)}` }); } } // System-wide checks const sysHealth = await apiCall('GET', '/system/health'); if (!sysHealth.error) { findings.push({ severity: sysHealth.status === 'healthy' ? 'ok' : 'warning', message: `System status: ${sysHealth.status}, services: ${JSON.stringify(sysHealth.checks?.services)}` }); if (sysHealth.checks?.memory?.percentage > 85) { findings.push({ severity: 'warning', message: `High memory usage: ${sysHealth.checks.memory.percentage}%` }); } } return { findings, depth: args.depth || 'standard' }; } // ── Backup & Recovery ── case 'dashcaddy_create_backup': { return apiCall('POST', '/disaster/backup'); } case 'dashcaddy_get_backup_status': { return apiCall('GET', '/disaster/status'); } // ── Fleet ── case 'dashcaddy_list_fleet': { return apiCall('GET', '/fleet/hosts'); } default: return { error: true, message: `Unknown tool: ${name}` }; } } // ─── MCP Protocol Handler ─────────────────────────────────────────────────── function handleMessage(msg) { const { id, method, params } = msg; switch (method) { case 'initialize': { return { jsonrpc: '2.0', id, result: { protocolVersion: MCP_VERSION, serverInfo: { name: 'dashcaddy', version: '1.15.0', }, capabilities: { tools: { listChanged: false }, resources: { listChanged: false, subscribe: false }, }, }, }; } case 'tools/list': { return { jsonrpc: '2.0', id, result: { tools: TOOLS }, }; } case 'tools/call': { const { name, arguments: args } = params; return handleTool(name, args).then(result => ({ jsonrpc: '2.0', id, result: { content: [{ type: 'text', text: JSON.stringify(result, null, 2), }], }, })).catch(err => ({ jsonrpc: '2.0', id, error: { code: -32603, message: err.message }, })); } case 'resources/list': { return { jsonrpc: '2.0', id, result: { resources: [ { uri: 'dashcaddy://services', name: 'Services', description: 'All DashCaddy services' }, { uri: 'dashcaddy://health', name: 'System Health', description: 'Current system health status' }, { uri: 'dashcaddy://catalog', name: 'App Catalog', description: 'Available self-hostable apps' }, ], }, }; } case 'ping': { return { jsonrpc: '2.0', id, result: {} }; } default: { if (id) { return { jsonrpc: '2.0', id, error: { code: -32601, message: `Method not found: ${method}` }, }; } // Notification — no response needed return null; } } } // ─── Stdio Transport ──────────────────────────────────────────────────────── const rl = readline.createInterface({ input: process.stdin, terminal: false }); process.stderr.write(`[DashCaddy MCP] Server starting — connecting to ${BASE_URL}\n`); rl.on('line', (line) => { if (!line.trim()) return; let msg; try { msg = JSON.parse(line); } catch { process.stderr.write(`[DashCaddy MCP] Invalid JSON: ${line.substring(0, 100)}\n`); return; } const response = handleMessage(msg); if (response && typeof response.then === 'function') { // Async handler response.then(res => { if (res) process.stdout.write(JSON.stringify(res) + '\n'); }).catch(err => { process.stderr.write(`[DashCaddy MCP] Error: ${err.message}\n`); }); } else if (response) { // Sync handler process.stdout.write(JSON.stringify(response) + '\n'); } // Notifications (no id) get no response }); rl.on('close', () => { process.stderr.write('[DashCaddy MCP] Server shutting down\n'); process.exit(0); });