import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs"; import { createHash, timingSafeEqual } from "node:crypto"; import { join } from "path"; import * as yaml from "js-yaml"; import { CONF_DIR } from "utils/config/config"; import { loadYaml } from "utils/config/yaml"; const PROTOCOL_VERSION = "2025-11-25"; const SERVER_INFO = { name: "homepage", version: "1.0.0", }; const MIN_TOKEN_LENGTH = 32; const CONFIG_FILES = [ "settings.yaml", "services.yaml", "bookmarks.yaml", "widgets.yaml", "docker.yaml", "kubernetes.yaml", "proxmox.yaml", "custom.css", "custom.js", ]; const YAML_CONFIG_FILES = CONFIG_FILES.filter((file) => file.endsWith(".yaml")); const DOC_LINKS = { "settings.yaml": "https://gethomepage.dev/configs/settings/", "services.yaml": "https://gethomepage.dev/configs/services/", "bookmarks.yaml": "https://gethomepage.dev/configs/bookmarks/", "widgets.yaml": "https://gethomepage.dev/configs/info-widgets/", "docker.yaml": "https://gethomepage.dev/configs/docker/", "kubernetes.yaml": "https://gethomepage.dev/configs/kubernetes/", "proxmox.yaml": "https://gethomepage.dev/configs/proxmox/", "custom.css": "https://gethomepage.dev/configs/custom-css-js/", "custom.js": "https://gethomepage.dev/configs/custom-css-js/", }; const FILE_DESCRIPTIONS = { "settings.yaml": "Application-level settings such as title, theme, providers, layout, language, and quicklaunch.", "services.yaml": "Service groups, links, icons, descriptions, widgets, and status checks shown on the dashboard.", "bookmarks.yaml": "Bookmark groups and links shown separately from services.", "widgets.yaml": "Information widgets such as resources, search, weather, calendar, and date/time widgets.", "docker.yaml": "Docker socket, TLS, and discovery settings for Docker-based automatic service discovery.", "kubernetes.yaml": "Kubernetes cluster and ingress discovery settings.", "proxmox.yaml": "Proxmox cluster settings used by Proxmox status features.", "custom.css": "Optional custom stylesheet loaded by Homepage.", "custom.js": "Optional custom JavaScript loaded by Homepage.", }; function enabled() { return process.env.HOMEPAGE_MCP_ENABLED === "true"; } function writeEnabled() { return process.env.HOMEPAGE_MCP_ALLOW_WRITE === "true"; } function requiredToken() { return process.env.HOMEPAGE_MCP_TOKEN; } function tokenMatches(provided, expectedDigest) { if (typeof provided !== "string") return false; const providedDigest = createHash("sha256").update(provided, "utf8").digest(); return timingSafeEqual(providedDigest, expectedDigest); } function jsonRpcResult(id, result) { return { jsonrpc: "2.0", id, result }; } function jsonRpcError(id, code, message, data) { return { jsonrpc: "2.0", id: id ?? null, error: { code, message, ...(data ? { data } : {}), }, }; } function textContent(text) { return { content: [ { type: "text", text, }, ], }; } function assertKnownConfigFile(file) { if (!CONFIG_FILES.includes(file)) { throw new Error(`Unsupported config file '${file}'. Supported files: ${CONFIG_FILES.join(", ")}`); } } function configPath(file) { assertKnownConfigFile(file); return join(CONF_DIR, file); } function fileExists(file) { return existsSync(configPath(file)); } function readConfig(file) { const path = configPath(file); return existsSync(path) ? readFileSync(path, "utf8") : ""; } function parseYamlConfig(file) { const parsed = loadYaml(readConfig(file) || ""); return parsed ?? []; } function validateYaml(file, content) { if (!YAML_CONFIG_FILES.includes(file)) { return { valid: true }; } try { loadYaml(content || ""); return { valid: true }; } catch (error) { return { valid: false, error: error.message, mark: error.mark ? { line: error.mark.line + 1, column: error.mark.column + 1, snippet: error.mark.snippet, } : undefined, }; } } function isPlainObject(value) { return value && typeof value === "object" && !Array.isArray(value); } function assertPlainObject(value, name) { if (!isPlainObject(value)) { throw new Error(`${name} must be an object`); } } function ensureWriteEnabled() { if (!writeEnabled()) { return { isError: true, ...textContent("Writing is disabled. Set HOMEPAGE_MCP_ALLOW_WRITE=true to enable MCP config edits."), }; } return null; } function dumpYamlConfig(file, content) { const dumped = yaml.dump(content, { lineWidth: -1, noRefs: true }); mkdirSync(CONF_DIR, { recursive: true }); writeFileSync(configPath(file), dumped, "utf8"); return dumped; } function addService(args) { const disabled = ensureWriteEnabled(); if (disabled) return disabled; if (typeof args.group !== "string" || !args.group.trim()) { throw new Error("group must be a non-empty string"); } if (typeof args.name !== "string" || !args.name.trim()) { throw new Error("name must be a non-empty string"); } const validation = validateYaml("services.yaml", readConfig("services.yaml")); if (!validation.valid) { return { isError: true, ...textContent(JSON.stringify(validation, null, 2)), }; } const services = parseYamlConfig("services.yaml"); if (!Array.isArray(services)) { throw new Error("services.yaml must contain a top-level array"); } const groupName = args.group.trim(); const serviceName = args.name.trim(); const serviceConfig = args.service ?? {}; assertPlainObject(serviceConfig, "service"); let group = services.find((entry) => isPlainObject(entry) && Object.keys(entry)[0] === groupName); if (!group) { group = { [groupName]: [] }; services.push(group); } if (!Array.isArray(group[groupName])) { throw new Error(`Group '${groupName}' must contain an array`); } if (group[groupName].some((entry) => isPlainObject(entry) && Object.keys(entry)[0] === serviceName)) { return { isError: true, ...textContent(`Service '${serviceName}' already exists in group '${groupName}'.`), }; } group[groupName].push({ [serviceName]: serviceConfig }); const content = dumpYamlConfig("services.yaml", services); return textContent( JSON.stringify({ written: "services.yaml", added: { group: groupName, service: serviceName }, content }, null, 2), ); } function addInfoWidget(args) { const disabled = ensureWriteEnabled(); if (disabled) return disabled; if (typeof args.type !== "string" || !args.type.trim()) { throw new Error("type must be a non-empty string"); } const validation = validateYaml("widgets.yaml", readConfig("widgets.yaml")); if (!validation.valid) { return { isError: true, ...textContent(JSON.stringify(validation, null, 2)), }; } const widgets = parseYamlConfig("widgets.yaml"); if (!Array.isArray(widgets)) { throw new Error("widgets.yaml must contain a top-level array"); } const type = args.type.trim(); const options = args.options ?? {}; assertPlainObject(options, "options"); widgets.push({ [type]: options }); const content = dumpYamlConfig("widgets.yaml", widgets); return textContent(JSON.stringify({ written: "widgets.yaml", added: { type }, content }, null, 2)); } function listConfigFiles() { return CONFIG_FILES.map((file) => ({ file, exists: fileExists(file), writable: writeEnabled(), description: FILE_DESCRIPTIONS[file], docs: DOC_LINKS[file], })); } function configResource(file) { return { uri: `homepage://config/${file}`, name: file, description: FILE_DESCRIPTIONS[file], mimeType: file.endsWith(".yaml") ? "application/yaml" : "text/plain", }; } function parseConfigResourceUri(uri) { const prefix = "homepage://config/"; if (!uri?.startsWith(prefix)) { throw new Error("Unsupported resource URI. Use homepage://config/."); } const file = uri.slice(prefix.length); assertKnownConfigFile(file); return file; } function toolDefinitions() { return [ { name: "list_config_files", description: "List Homepage config files this server understands, whether they currently exist, and where their docs live.", inputSchema: { type: "object", properties: {}, }, }, { name: "read_config_file", description: "Read one supported Homepage config file from HOMEPAGE_CONFIG_DIR. Missing files return empty content.", inputSchema: { type: "object", properties: { file: { type: "string", enum: CONFIG_FILES }, }, required: ["file"], }, }, { name: "validate_config_file", description: "Validate YAML syntax for a supported Homepage config file or supplied content and return line/column details for YAML errors.", inputSchema: { type: "object", properties: { file: { type: "string", enum: YAML_CONFIG_FILES }, content: { type: "string", description: "Optional YAML content to validate instead of reading the file." }, }, required: ["file"], }, }, { name: "write_config_file", description: "Replace a supported Homepage config file. Disabled unless HOMEPAGE_MCP_ALLOW_WRITE=true. YAML files are validated before writing.", inputSchema: { type: "object", properties: { file: { type: "string", enum: CONFIG_FILES }, content: { type: "string" }, }, required: ["file", "content"], }, }, { name: "add_service", description: "Append a service to a group in services.yaml, creating the group if needed. Disabled unless HOMEPAGE_MCP_ALLOW_WRITE=true.", inputSchema: { type: "object", properties: { group: { type: "string", description: "Existing or new Homepage service group name." }, name: { type: "string", description: "Service display name." }, service: { type: "object", description: "Homepage service properties such as href, icon, description, server, container, widget, or widgets.", additionalProperties: true, }, }, required: ["group", "name"], }, }, { name: "add_info_widget", description: "Append an information widget to widgets.yaml. Disabled unless HOMEPAGE_MCP_ALLOW_WRITE=true.", inputSchema: { type: "object", properties: { type: { type: "string", description: "Homepage info widget type, for example resources, search, datetime, or openmeteo.", }, options: { type: "object", description: "Widget options for the selected info widget type.", additionalProperties: true, }, }, required: ["type"], }, }, { name: "homepage_docs", description: "Return focused Homepage documentation links for config files and troubleshooting.", inputSchema: { type: "object", properties: { topic: { type: "string", enum: ["overview", ...CONFIG_FILES, "troubleshooting", "widgets"], }, }, }, }, ]; } function callTool(name, args = {}) { switch (name) { case "list_config_files": return textContent(JSON.stringify({ configDir: CONF_DIR, files: listConfigFiles() }, null, 2)); case "read_config_file": { assertKnownConfigFile(args.file); return textContent(readConfig(args.file)); } case "validate_config_file": { assertKnownConfigFile(args.file); const content = Object.prototype.hasOwnProperty.call(args, "content") ? args.content : readConfig(args.file); return textContent(JSON.stringify(validateYaml(args.file, content), null, 2)); } case "write_config_file": { const disabled = ensureWriteEnabled(); if (disabled) return disabled; assertKnownConfigFile(args.file); if (typeof args.content !== "string") { throw new Error("content must be a string"); } const validation = validateYaml(args.file, args.content); if (!validation.valid) { return { isError: true, ...textContent(JSON.stringify(validation, null, 2)), }; } mkdirSync(CONF_DIR, { recursive: true }); writeFileSync(configPath(args.file), args.content, "utf8"); return textContent( JSON.stringify({ written: args.file, bytes: Buffer.byteLength(args.content, "utf8") }, null, 2), ); } case "add_service": return addService(args); case "add_info_widget": return addInfoWidget(args); case "homepage_docs": { const topic = args.topic || "overview"; const links = { overview: "https://gethomepage.dev/configs/", troubleshooting: "https://gethomepage.dev/troubleshooting/", widgets: "https://gethomepage.dev/widgets/", ...DOC_LINKS, }; return textContent(JSON.stringify({ topic, url: links[topic] || links.overview }, null, 2)); } default: throw new Error(`Unknown tool '${name}'`); } } export function mcpEnabled() { return enabled(); } export function mcpTokenConfigError() { if (!enabled()) return null; const token = requiredToken(); if (token && token.length < MIN_TOKEN_LENGTH) { return `HOMEPAGE_MCP_TOKEN must be at least ${MIN_TOKEN_LENGTH} characters. Generate one with: openssl rand -base64 32`; } return null; } export function mcpTokenAuthorized(req) { const token = requiredToken(); if (!token || token.length < MIN_TOKEN_LENGTH) return false; const authHeader = req.headers.authorization; const bearerToken = typeof authHeader === "string" && authHeader.startsWith("Bearer ") ? authHeader.slice(7) : null; const expectedDigest = createHash("sha256").update(token, "utf8").digest(); return tokenMatches(bearerToken, expectedDigest) || tokenMatches(req.headers["x-homepage-mcp-token"], expectedDigest); } export function handleMcpRequest(message) { if (!message || message.jsonrpc !== "2.0" || typeof message.method !== "string") { return jsonRpcError(message?.id, -32600, "Invalid JSON-RPC request"); } if (message.method.startsWith("notifications/")) { return null; } try { switch (message.method) { case "initialize": return jsonRpcResult(message.id, { protocolVersion: PROTOCOL_VERSION, capabilities: { tools: {}, resources: {}, }, serverInfo: SERVER_INFO, instructions: "Homepage MCP helps inspect and validate Homepage YAML configuration. File writes are disabled unless HOMEPAGE_MCP_ALLOW_WRITE=true.", }); case "tools/list": return jsonRpcResult(message.id, { tools: toolDefinitions() }); case "tools/call": return jsonRpcResult(message.id, callTool(message.params?.name, message.params?.arguments ?? {})); case "resources/list": return jsonRpcResult(message.id, { resources: CONFIG_FILES.map(configResource) }); case "resources/read": { const file = parseConfigResourceUri(message.params?.uri); return jsonRpcResult(message.id, { contents: [ { uri: message.params.uri, mimeType: file.endsWith(".yaml") ? "application/yaml" : "text/plain", text: readConfig(file), }, ], }); } default: return jsonRpcError(message.id, -32601, `Method not found: ${message.method}`); } } catch (error) { return jsonRpcError(message.id, -32602, error.message); } }