mirror of
https://github.com/gethomepage/homepage.git
synced 2026-09-29 15:31:16 -07:00
Fix: prevent mcp operations losing var placeholders (#7144)
This commit is contained in:
@@ -118,9 +118,19 @@ function readConfig(file) {
|
|||||||
return existsSync(path) ? readFileSync(path, "utf8") : "";
|
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) {
|
function parseYamlConfig(file) {
|
||||||
const parsed = loadYaml(readConfig(file) || "");
|
const placeholders = [];
|
||||||
return parsed ?? [];
|
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) {
|
function validateYaml(file, content) {
|
||||||
@@ -166,8 +176,10 @@ function ensureWriteEnabled() {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function dumpYamlConfig(file, content) {
|
function dumpYamlConfig(file, content, placeholders) {
|
||||||
const dumped = yaml.dump(content, { lineWidth: -1, noRefs: true });
|
const dumped = yaml
|
||||||
|
.dump(content, { lineWidth: -1, noRefs: true })
|
||||||
|
.replace(PLACEHOLDER_TOKEN_PATTERN, (match, index) => placeholders[index] ?? match);
|
||||||
mkdirSync(CONF_DIR, { recursive: true });
|
mkdirSync(CONF_DIR, { recursive: true });
|
||||||
writeFileSync(configPath(file), dumped, "utf8");
|
writeFileSync(configPath(file), dumped, "utf8");
|
||||||
return dumped;
|
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)) {
|
if (!Array.isArray(services)) {
|
||||||
throw new Error("services.yaml must contain a top-level array");
|
throw new Error("services.yaml must contain a top-level array");
|
||||||
}
|
}
|
||||||
@@ -220,7 +232,7 @@ function addService(args) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
group[groupName].push({ [serviceName]: serviceConfig });
|
group[groupName].push({ [serviceName]: serviceConfig });
|
||||||
const content = dumpYamlConfig("services.yaml", services);
|
const content = dumpYamlConfig("services.yaml", services, placeholders);
|
||||||
return textContent(
|
return textContent(
|
||||||
JSON.stringify({ written: "services.yaml", added: { group: groupName, service: serviceName }, content }, null, 2),
|
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)) {
|
if (!Array.isArray(widgets)) {
|
||||||
throw new Error("widgets.yaml must contain a top-level array");
|
throw new Error("widgets.yaml must contain a top-level array");
|
||||||
}
|
}
|
||||||
@@ -252,7 +264,7 @@ function addInfoWidget(args) {
|
|||||||
assertPlainObject(options, "options");
|
assertPlainObject(options, "options");
|
||||||
|
|
||||||
widgets.push({ [type]: 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));
|
return textContent(JSON.stringify({ written: "widgets.yaml", added: { type }, content }, null, 2));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 () => {
|
it("does not add a duplicate service in the same group", async () => {
|
||||||
process.env.HOMEPAGE_MCP_ALLOW_WRITE = "true";
|
process.env.HOMEPAGE_MCP_ALLOW_WRITE = "true";
|
||||||
const configDir = mkdtempSync(path.join(tmpdir(), "homepage-mcp-test-"));
|
const configDir = mkdtempSync(path.join(tmpdir(), "homepage-mcp-test-"));
|
||||||
|
|||||||
Reference in New Issue
Block a user