mirror of
https://github.com/gethomepage/homepage.git
synced 2026-10-02 17:01:15 -07:00
Support add service/widget
This commit is contained in:
@@ -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`. |
|
| `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. |
|
| `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`. |
|
| `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. |
|
| `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
|
## Supported resources
|
||||||
|
|
||||||
The MCP server also exposes config files as MCP resources using this URI format:
|
The MCP server also exposes config files as MCP resources using this URI format:
|
||||||
|
|||||||
@@ -108,6 +108,11 @@ function readConfig(file) {
|
|||||||
return existsSync(path) ? readFileSync(path, "utf8") : "";
|
return existsSync(path) ? readFileSync(path, "utf8") : "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseYamlConfig(file) {
|
||||||
|
const parsed = yaml.load(readConfig(file) || "");
|
||||||
|
return parsed ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
function validateYaml(file, content) {
|
function validateYaml(file, content) {
|
||||||
if (!YAML_CONFIG_FILES.includes(file)) {
|
if (!YAML_CONFIG_FILES.includes(file)) {
|
||||||
return { valid: true };
|
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() {
|
function listConfigFiles() {
|
||||||
return CONFIG_FILES.map((file) => ({
|
return CONFIG_FILES.map((file) => ({
|
||||||
file,
|
file,
|
||||||
@@ -209,6 +324,44 @@ function toolDefinitions() {
|
|||||||
required: ["file", "content"],
|
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",
|
name: "homepage_docs",
|
||||||
description: "Return focused Homepage documentation links for config files and troubleshooting.",
|
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));
|
return textContent(JSON.stringify(validateYaml(args.file, content), null, 2));
|
||||||
}
|
}
|
||||||
case "write_config_file": {
|
case "write_config_file": {
|
||||||
if (!writeEnabled()) {
|
const disabled = ensureWriteEnabled();
|
||||||
return {
|
if (disabled) return disabled;
|
||||||
isError: true,
|
|
||||||
...textContent("Writing is disabled. Set HOMEPAGE_MCP_ALLOW_WRITE=true to enable MCP config edits."),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
assertKnownConfigFile(args.file);
|
assertKnownConfigFile(args.file);
|
||||||
if (typeof args.content !== "string") {
|
if (typeof args.content !== "string") {
|
||||||
throw new Error("content must be a 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),
|
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": {
|
case "homepage_docs": {
|
||||||
const topic = args.topic || "overview";
|
const topic = args.topic || "overview";
|
||||||
const links = {
|
const links = {
|
||||||
|
|||||||
@@ -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("validate_config_file");
|
||||||
expect(response.result.tools.map((tool) => tool.name)).toContain("write_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 () => {
|
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");
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user