From d96799c9859fb9ca86eea13ccdf62dff65517ff5 Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Fri, 12 Jun 2026 10:04:04 -0700 Subject: [PATCH] Support add service/widget --- docs/configs/mcp.md | 35 ++++++ src/utils/mcp/homepage-mcp.js | 166 +++++++++++++++++++++++++++-- src/utils/mcp/homepage-mcp.test.js | 133 +++++++++++++++++++++++ 3 files changed, 328 insertions(+), 6 deletions(-) diff --git a/docs/configs/mcp.md b/docs/configs/mcp.md index 1c3202007..d6c4cd86b 100644 --- a/docs/configs/mcp.md +++ b/docs/configs/mcp.md @@ -60,8 +60,43 @@ When writes are enabled, YAML files are parsed before they are saved so a syntac | `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`. | +| `add_service` | Appends a service to a group in `services.yaml` when `HOMEPAGE_MCP_ALLOW_WRITE=true`. | +| `add_info_widget` | Appends an information widget to `widgets.yaml` when `HOMEPAGE_MCP_ALLOW_WRITE=true`. | | `homepage_docs` | Returns focused Homepage documentation links for common setup topics. | +The structured write tools are safer than replacing an entire file because they parse the existing YAML, preserve the expected top-level structure, and only append a Homepage-shaped entry. For example, an assistant can add a service with: + +```json +{ + "group": "Media", + "name": "Plex", + "service": { + "href": "https://plex.example.com", + "icon": "plex.png", + "description": "Movies and TV", + "widget": { + "type": "plex", + "url": "https://plex.example.com", + "key": "your-api-key" + } + } +} +``` + +Or add an info widget with: + +```json +{ + "type": "openmeteo", + "options": { + "label": "Current", + "latitude": 36.66, + "longitude": -117.51, + "cache": 5 + } +} +``` + ## Supported resources The MCP server also exposes config files as MCP resources using this URI format: diff --git a/src/utils/mcp/homepage-mcp.js b/src/utils/mcp/homepage-mcp.js index e4d86f59a..5726a82ae 100644 --- a/src/utils/mcp/homepage-mcp.js +++ b/src/utils/mcp/homepage-mcp.js @@ -108,6 +108,11 @@ function readConfig(file) { return existsSync(path) ? readFileSync(path, "utf8") : ""; } +function parseYamlConfig(file) { + const parsed = yaml.load(readConfig(file) || ""); + return parsed ?? []; +} + function validateYaml(file, content) { if (!YAML_CONFIG_FILES.includes(file)) { return { valid: true }; @@ -131,6 +136,116 @@ function validateYaml(file, content) { } } +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, @@ -209,6 +324,44 @@ function toolDefinitions() { 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.", @@ -239,12 +392,9 @@ function callTool(name, args = {}) { 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."), - }; - } + const disabled = ensureWriteEnabled(); + if (disabled) return disabled; + assertKnownConfigFile(args.file); if (typeof args.content !== "string") { throw new Error("content must be a string"); @@ -262,6 +412,10 @@ function callTool(name, args = {}) { 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 = { diff --git a/src/utils/mcp/homepage-mcp.test.js b/src/utils/mcp/homepage-mcp.test.js index 51d0182c9..8bf820f7f 100644 --- a/src/utils/mcp/homepage-mcp.test.js +++ b/src/utils/mcp/homepage-mcp.test.js @@ -46,6 +46,8 @@ describe("utils/mcp/homepage-mcp", () => { expect(response.result.tools.map((tool) => tool.name)).toContain("validate_config_file"); expect(response.result.tools.map((tool) => tool.name)).toContain("write_config_file"); + expect(response.result.tools.map((tool) => tool.name)).toContain("add_service"); + expect(response.result.tools.map((tool) => tool.name)).toContain("add_info_widget"); }); it("validates YAML and reports line and column details", async () => { @@ -139,4 +141,135 @@ describe("utils/mcp/homepage-mcp", () => { expect(response.result.contents[0].text).toBe("- Links: []\n"); }); + + it("adds a service to a new group 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: 8, + method: "tools/call", + params: { + name: "add_service", + arguments: { + group: "Media", + name: "Plex", + service: { + href: "https://plex.example.com", + icon: "plex.png", + description: "Movies and TV", + widget: { + type: "plex", + url: "https://plex.example.com", + key: "secret", + }, + }, + }, + }, + }); + + expect(response.result.isError).toBeUndefined(); + expect(readFileSync(path.join(configDir, "services.yaml"), "utf8")).toBe( + "- Media:\n" + + " - Plex:\n" + + " href: https://plex.example.com\n" + + " icon: plex.png\n" + + " description: Movies and TV\n" + + " widget:\n" + + " type: plex\n" + + " url: https://plex.example.com\n" + + " key: secret\n", + ); + }); + + it("does not add a duplicate service in the same group", async () => { + process.env.HOMEPAGE_MCP_ALLOW_WRITE = "true"; + const configDir = mkdtempSync(path.join(tmpdir(), "homepage-mcp-test-")); + const mod = await loadMcpWithConfigDir(configDir); + const request = { + jsonrpc: "2.0", + method: "tools/call", + params: { + name: "add_service", + arguments: { + group: "Media", + name: "Plex", + service: { href: "https://plex.example.com" }, + }, + }, + }; + + mod.handleMcpRequest({ ...request, id: 9 }); + const response = mod.handleMcpRequest({ ...request, id: 10 }); + + expect(response.result.isError).toBe(true); + expect(response.result.content[0].text).toContain("already exists"); + }); + + it("adds an info widget 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: 11, + method: "tools/call", + params: { + name: "add_info_widget", + arguments: { + type: "openmeteo", + options: { + label: "Current", + latitude: 36.66, + longitude: -117.51, + cache: 5, + }, + }, + }, + }); + + expect(response.result.isError).toBeUndefined(); + expect(readFileSync(path.join(configDir, "widgets.yaml"), "utf8")).toBe( + "- openmeteo:\n" + + " label: Current\n" + + " latitude: 36.66\n" + + " longitude: -117.51\n" + + " cache: 5\n", + ); + }); + + it("does not add services or info widgets unless write mode is enabled", async () => { + const configDir = mkdtempSync(path.join(tmpdir(), "homepage-mcp-test-")); + const mod = await loadMcpWithConfigDir(configDir); + + const serviceResponse = mod.handleMcpRequest({ + jsonrpc: "2.0", + id: 12, + method: "tools/call", + params: { + name: "add_service", + arguments: { + group: "Media", + name: "Plex", + }, + }, + }); + const widgetResponse = mod.handleMcpRequest({ + jsonrpc: "2.0", + id: 13, + method: "tools/call", + params: { + name: "add_info_widget", + arguments: { + type: "resources", + }, + }, + }); + + expect(serviceResponse.result.isError).toBe(true); + expect(widgetResponse.result.isError).toBe(true); + }); });