diff --git a/src/utils/mcp/homepage-mcp.js b/src/utils/mcp/homepage-mcp.js index 65c28e494..a4590ba2e 100644 --- a/src/utils/mcp/homepage-mcp.js +++ b/src/utils/mcp/homepage-mcp.js @@ -118,9 +118,19 @@ function readConfig(file) { return existsSync(path) ? readFileSync(path, "utf8") : ""; } +const PLACEHOLDER_PATTERN = /(["']?)\{\{([^{}]*)\}\}\1/g; +const HOMEPAGE_KEY_PATTERN = /HOMEPAGE_(?:VAR|FILE)_/; +const PLACEHOLDER_TOKEN_PATTERN = /__HOMEPAGE_MCP_PLACEHOLDER_(\d+)__/g; + +// mask {{HOMEPAGE_*}} placeholders so they survive a parse/dump round-trip function parseYamlConfig(file) { - const parsed = loadYaml(readConfig(file) || ""); - return parsed ?? []; + const placeholders = []; + const masked = readConfig(file).replace(PLACEHOLDER_PATTERN, (match, quote, key) => { + if (!HOMEPAGE_KEY_PATTERN.test(key)) return match; + placeholders.push(match); + return `__HOMEPAGE_MCP_PLACEHOLDER_${placeholders.length - 1}__`; + }); + return { data: loadYaml(masked) ?? [], placeholders }; } function validateYaml(file, content) { @@ -166,8 +176,10 @@ function ensureWriteEnabled() { return null; } -function dumpYamlConfig(file, content) { - const dumped = yaml.dump(content, { lineWidth: -1, noRefs: true }); +function dumpYamlConfig(file, content, placeholders) { + const dumped = yaml + .dump(content, { lineWidth: -1, noRefs: true }) + .replace(PLACEHOLDER_TOKEN_PATTERN, (match, index) => placeholders[index] ?? match); mkdirSync(CONF_DIR, { recursive: true }); writeFileSync(configPath(file), dumped, "utf8"); return dumped; @@ -192,7 +204,7 @@ function addService(args) { }; } - const services = parseYamlConfig("services.yaml"); + const { data: services, placeholders } = parseYamlConfig("services.yaml"); if (!Array.isArray(services)) { throw new Error("services.yaml must contain a top-level array"); } @@ -220,7 +232,7 @@ function addService(args) { } group[groupName].push({ [serviceName]: serviceConfig }); - const content = dumpYamlConfig("services.yaml", services); + const content = dumpYamlConfig("services.yaml", services, placeholders); return textContent( JSON.stringify({ written: "services.yaml", added: { group: groupName, service: serviceName }, content }, null, 2), ); @@ -242,7 +254,7 @@ function addInfoWidget(args) { }; } - const widgets = parseYamlConfig("widgets.yaml"); + const { data: widgets, placeholders } = parseYamlConfig("widgets.yaml"); if (!Array.isArray(widgets)) { throw new Error("widgets.yaml must contain a top-level array"); } @@ -252,7 +264,7 @@ function addInfoWidget(args) { assertPlainObject(options, "options"); widgets.push({ [type]: options }); - const content = dumpYamlConfig("widgets.yaml", widgets); + const content = dumpYamlConfig("widgets.yaml", widgets, placeholders); return textContent(JSON.stringify({ written: "widgets.yaml", added: { type }, content }, null, 2)); } diff --git a/src/utils/mcp/homepage-mcp.test.js b/src/utils/mcp/homepage-mcp.test.js index d88dfd5dc..b5fb962ab 100644 --- a/src/utils/mcp/homepage-mcp.test.js +++ b/src/utils/mcp/homepage-mcp.test.js @@ -184,6 +184,42 @@ describe("utils/mcp/homepage-mcp", () => { ); }); + it("preserves env placeholders when adding a service", async () => { + process.env.HOMEPAGE_MCP_ALLOW_WRITE = "true"; + const configDir = mkdtempSync(path.join(tmpdir(), "homepage-mcp-test-")); + writeFileSync( + path.join(configDir, "services.yaml"), + "- Media:\n" + + " - Jellyfin:\n" + + " href: http://{{HOMEPAGE_VAR_HOST}}:8096\n" + + " widget:\n" + + " type: jellyfin\n" + + " key: {{HOMEPAGE_VAR_JELLYFIN_KEY}}\n" + + ' password: "{{HOMEPAGE_FILE_PASSWORD}}"\n', + ); + const mod = await loadMcpWithConfigDir(configDir); + + const response = mod.handleMcpRequest({ + jsonrpc: "2.0", + id: 11, + method: "tools/call", + params: { name: "add_service", arguments: { group: "Tools", name: "Grafana" } }, + }); + + expect(response.result.isError).toBeUndefined(); + expect(readFileSync(path.join(configDir, "services.yaml"), "utf8")).toBe( + "- Media:\n" + + " - Jellyfin:\n" + + " href: http://{{HOMEPAGE_VAR_HOST}}:8096\n" + + " widget:\n" + + " type: jellyfin\n" + + " key: {{HOMEPAGE_VAR_JELLYFIN_KEY}}\n" + + ' password: "{{HOMEPAGE_FILE_PASSWORD}}"\n' + + "- Tools:\n" + + " - Grafana: {}\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-"));