mirror of
https://github.com/gethomepage/homepage.git
synced 2026-09-26 22:11:20 -07:00
Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: shamoon <4887959+shamoon@users.noreply.github.com>
514 lines
16 KiB
JavaScript
514 lines
16 KiB
JavaScript
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/<filename>.");
|
|
}
|
|
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);
|
|
}
|
|
}
|