From 0da0aaa7d39b34921eeee4512252eade60cd0efa Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Fri, 12 Jun 2026 08:40:23 -0700 Subject: [PATCH] Feature: homepage MCP --- docs/configs/mcp.md | 92 ++++++++ mkdocs.yml | 1 + src/pages/api/mcp/index.js | 23 ++ src/pages/api/mcp/index.test.js | 94 ++++++++ src/utils/mcp/homepage-mcp.js | 338 +++++++++++++++++++++++++++++ src/utils/mcp/homepage-mcp.test.js | 142 ++++++++++++ 6 files changed, 690 insertions(+) create mode 100644 docs/configs/mcp.md create mode 100644 src/pages/api/mcp/index.js create mode 100644 src/pages/api/mcp/index.test.js create mode 100644 src/utils/mcp/homepage-mcp.js create mode 100644 src/utils/mcp/homepage-mcp.test.js diff --git a/docs/configs/mcp.md b/docs/configs/mcp.md new file mode 100644 index 000000000..1c3202007 --- /dev/null +++ b/docs/configs/mcp.md @@ -0,0 +1,92 @@ +--- +title: Model Context Protocol +--- + +Homepage includes an optional, lightweight [Model Context Protocol](https://modelcontextprotocol.io/) endpoint that can help AI assistants inspect, validate, and, if you explicitly allow it, update Homepage configuration files. + +This endpoint is **disabled by default**. Do not expose it to an untrusted network unless you put Homepage behind authentication, TLS, and a reverse proxy that validates Host headers. + +## Enable the MCP endpoint + +Set the following environment variable and restart Homepage: + +```yaml +HOMEPAGE_MCP_ENABLED: "true" +``` + +The endpoint is available at: + +```txt +http://your-homepage-instance/api/mcp +``` + +## Authentication + +If `HOMEPAGE_MCP_TOKEN` is set, MCP requests must include either of the following headers: + +```txt +Authorization: Bearer your-token +``` + +or: + +```txt +X-Homepage-MCP-Token: your-token +``` + +Example Docker Compose environment block: + +```yaml +environment: + HOMEPAGE_MCP_ENABLED: "true" + HOMEPAGE_MCP_TOKEN: "change-me" +``` + +## Read-only by default + +The MCP endpoint exposes tools for reading and validating supported Homepage config files. File writes are disabled unless you opt in with: + +```yaml +HOMEPAGE_MCP_ALLOW_WRITE: "true" +``` + +When writes are enabled, YAML files are parsed before they are saved so a syntactically invalid YAML document is rejected instead of replacing the current file. + +## Supported tools + +| Tool | Description | +| ---------------------- | ----------------------------------------------------------------------------------------------------- | +| `list_config_files` | Lists supported config files, whether they exist, and links to the related docs. | +| `read_config_file` | Reads one supported config file from `HOMEPAGE_CONFIG_DIR`. | +| `validate_config_file` | Validates YAML from a file or supplied content and returns line and column details for syntax errors. | +| `write_config_file` | Replaces a supported config file when `HOMEPAGE_MCP_ALLOW_WRITE=true`. | +| `homepage_docs` | Returns focused Homepage documentation links for common setup topics. | + +## Supported resources + +The MCP server also exposes config files as MCP resources using this URI format: + +```txt +homepage://config/services.yaml +``` + +Supported files are: + +- `settings.yaml` +- `services.yaml` +- `bookmarks.yaml` +- `widgets.yaml` +- `docker.yaml` +- `kubernetes.yaml` +- `proxmox.yaml` +- `custom.css` +- `custom.js` + +## Example request + +```bash +curl -s http://localhost:3000/api/mcp \ + -H 'Content-Type: application/json' \ + -H 'Authorization: Bearer your-token' \ + -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' +``` diff --git a/mkdocs.yml b/mkdocs.yml index e954e0219..4e993d483 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -26,6 +26,7 @@ nav: - configs/kubernetes.md - configs/docker.md - configs/proxmox.md + - configs/mcp.md - configs/custom-css-js.md - "Widgets": - widgets/index.md diff --git a/src/pages/api/mcp/index.js b/src/pages/api/mcp/index.js new file mode 100644 index 000000000..bb77fff86 --- /dev/null +++ b/src/pages/api/mcp/index.js @@ -0,0 +1,23 @@ +import { handleMcpRequest, mcpAuthorized, mcpEnabled } from "utils/mcp/homepage-mcp"; + +export default async function handler(req, res) { + if (!mcpEnabled()) { + return res.status(404).end("Not Found"); + } + + if (!mcpAuthorized(req)) { + return res.status(401).json({ error: "Unauthorized" }); + } + + if (req.method !== "POST") { + res.setHeader("Allow", "POST"); + return res.status(405).end("Method Not Allowed"); + } + + const response = handleMcpRequest(req.body); + if (!response) { + return res.status(202).end(); + } + + return res.status(200).json(response); +} diff --git a/src/pages/api/mcp/index.test.js b/src/pages/api/mcp/index.test.js new file mode 100644 index 000000000..f3ed88d92 --- /dev/null +++ b/src/pages/api/mcp/index.test.js @@ -0,0 +1,94 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +function mockResponse() { + const res = { + statusCode: 200, + headers: {}, + body: undefined, + setHeader: vi.fn((key, value) => { + res.headers[key] = value; + }), + status: vi.fn((code) => { + res.statusCode = code; + return res; + }), + json: vi.fn((body) => { + res.body = body; + return res; + }), + end: vi.fn((body) => { + res.body = body; + return res; + }), + }; + return res; +} + +async function loadHandler() { + vi.resetModules(); + return (await import("./index")).default; +} + +describe("pages/api/mcp", () => { + const originalEnv = process.env; + + beforeEach(() => { + vi.resetModules(); + process.env = { ...originalEnv }; + }); + + afterEach(() => { + process.env = originalEnv; + }); + + it("returns 404 while disabled", async () => { + delete process.env.HOMEPAGE_MCP_ENABLED; + const handler = await loadHandler(); + const res = mockResponse(); + + await handler({ method: "POST", headers: {}, body: { jsonrpc: "2.0", id: 1, method: "tools/list" } }, res); + + expect(res.status).toHaveBeenCalledWith(404); + }); + + it("requires bearer token when configured", async () => { + process.env.HOMEPAGE_MCP_ENABLED = "true"; + process.env.HOMEPAGE_MCP_TOKEN = "secret"; + const handler = await loadHandler(); + const res = mockResponse(); + + await handler({ method: "POST", headers: {}, body: { jsonrpc: "2.0", id: 1, method: "tools/list" } }, res); + + expect(res.status).toHaveBeenCalledWith(401); + }); + + it("handles JSON-RPC requests when enabled and authorized", async () => { + process.env.HOMEPAGE_MCP_ENABLED = "true"; + process.env.HOMEPAGE_MCP_TOKEN = "secret"; + const handler = await loadHandler(); + const res = mockResponse(); + + await handler( + { + method: "POST", + headers: { authorization: "Bearer secret" }, + body: { jsonrpc: "2.0", id: 1, method: "tools/list" }, + }, + res, + ); + + expect(res.status).toHaveBeenCalledWith(200); + expect(res.body.result.tools.length).toBeGreaterThan(0); + }); + + it("rejects non-POST requests", async () => { + process.env.HOMEPAGE_MCP_ENABLED = "true"; + const handler = await loadHandler(); + const res = mockResponse(); + + await handler({ method: "GET", headers: {}, body: {} }, res); + + expect(res.status).toHaveBeenCalledWith(405); + expect(res.setHeader).toHaveBeenCalledWith("Allow", "POST"); + }); +}); diff --git a/src/utils/mcp/homepage-mcp.js b/src/utils/mcp/homepage-mcp.js new file mode 100644 index 000000000..e4d86f59a --- /dev/null +++ b/src/utils/mcp/homepage-mcp.js @@ -0,0 +1,338 @@ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs"; +import { join } from "path"; + +import yaml from "js-yaml"; + +import { CONF_DIR } from "utils/config/config"; + +const PROTOCOL_VERSION = "2025-11-25"; +const SERVER_INFO = { + name: "homepage", + version: "1.0.0", +}; + +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 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 validateYaml(file, content) { + if (!YAML_CONFIG_FILES.includes(file)) { + return { valid: true }; + } + + try { + yaml.load(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 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: "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": { + if (!writeEnabled()) { + return { + isError: true, + ...textContent("Writing is disabled. Set HOMEPAGE_MCP_ALLOW_WRITE=true to enable MCP config edits."), + }; + } + 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 "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 mcpAuthorized(req) { + const token = requiredToken(); + if (!token) return true; + + const authHeader = req.headers.authorization; + return authHeader === `Bearer ${token}` || req.headers["x-homepage-mcp-token"] === token; +} + +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); + } +} diff --git a/src/utils/mcp/homepage-mcp.test.js b/src/utils/mcp/homepage-mcp.test.js new file mode 100644 index 000000000..51d0182c9 --- /dev/null +++ b/src/utils/mcp/homepage-mcp.test.js @@ -0,0 +1,142 @@ +import { mkdtempSync, readFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +async function loadMcpWithConfigDir(configDir) { + vi.resetModules(); + process.env.HOMEPAGE_CONFIG_DIR = configDir; + return import("./homepage-mcp"); +} + +describe("utils/mcp/homepage-mcp", () => { + const originalEnv = process.env; + + beforeEach(() => { + vi.resetModules(); + process.env = { ...originalEnv }; + }); + + afterEach(() => { + process.env = originalEnv; + }); + + it("is disabled by default", async () => { + delete process.env.HOMEPAGE_MCP_ENABLED; + + const mod = await loadMcpWithConfigDir(mkdtempSync(path.join(tmpdir(), "homepage-mcp-test-"))); + + expect(mod.mcpEnabled()).toBe(false); + }); + + it("returns initialize capabilities", async () => { + const mod = await loadMcpWithConfigDir(mkdtempSync(path.join(tmpdir(), "homepage-mcp-test-"))); + + const response = mod.handleMcpRequest({ jsonrpc: "2.0", id: 1, method: "initialize", params: {} }); + + expect(response.result.protocolVersion).toBe("2025-11-25"); + expect(response.result.capabilities).toEqual({ tools: {}, resources: {} }); + }); + + it("lists Homepage configuration tools", async () => { + const mod = await loadMcpWithConfigDir(mkdtempSync(path.join(tmpdir(), "homepage-mcp-test-"))); + + const response = mod.handleMcpRequest({ jsonrpc: "2.0", id: 2, method: "tools/list" }); + + expect(response.result.tools.map((tool) => tool.name)).toContain("validate_config_file"); + expect(response.result.tools.map((tool) => tool.name)).toContain("write_config_file"); + }); + + it("validates YAML and reports line and column details", async () => { + const mod = await loadMcpWithConfigDir(mkdtempSync(path.join(tmpdir(), "homepage-mcp-test-"))); + + const response = mod.handleMcpRequest({ + jsonrpc: "2.0", + id: 3, + method: "tools/call", + params: { + name: "validate_config_file", + arguments: { + file: "services.yaml", + content: "- Group:\n - Broken: [", + }, + }, + }); + + const validation = JSON.parse(response.result.content[0].text); + expect(validation.valid).toBe(false); + expect(validation.mark.line).toBeGreaterThan(0); + expect(validation.mark.column).toBeGreaterThan(0); + }); + + it("does not write configuration files unless write mode is enabled", async () => { + const configDir = mkdtempSync(path.join(tmpdir(), "homepage-mcp-test-")); + const mod = await loadMcpWithConfigDir(configDir); + + const response = mod.handleMcpRequest({ + jsonrpc: "2.0", + id: 4, + method: "tools/call", + params: { + name: "write_config_file", + arguments: { + file: "settings.yaml", + content: "title: Test\n", + }, + }, + }); + + expect(response.result.isError).toBe(true); + }); + + it("writes valid YAML when write mode is enabled", async () => { + process.env.HOMEPAGE_MCP_ALLOW_WRITE = "true"; + const configDir = mkdtempSync(path.join(tmpdir(), "homepage-mcp-test-")); + const mod = await loadMcpWithConfigDir(configDir); + + const response = mod.handleMcpRequest({ + jsonrpc: "2.0", + id: 5, + method: "tools/call", + params: { + name: "write_config_file", + arguments: { + file: "settings.yaml", + content: "title: Test\n", + }, + }, + }); + + expect(response.result.isError).toBeUndefined(); + expect(readFileSync(path.join(configDir, "settings.yaml"), "utf8")).toBe("title: Test\n"); + }); + + it("reads config resources", async () => { + process.env.HOMEPAGE_MCP_ALLOW_WRITE = "true"; + const configDir = mkdtempSync(path.join(tmpdir(), "homepage-mcp-test-")); + const mod = await loadMcpWithConfigDir(configDir); + + mod.handleMcpRequest({ + jsonrpc: "2.0", + id: 6, + method: "tools/call", + params: { + name: "write_config_file", + arguments: { + file: "bookmarks.yaml", + content: "- Links: []\n", + }, + }, + }); + + const response = mod.handleMcpRequest({ + jsonrpc: "2.0", + id: 7, + method: "resources/read", + params: { uri: "homepage://config/bookmarks.yaml" }, + }); + + expect(response.result.contents[0].text).toBe("- Links: []\n"); + }); +});