mirror of
https://github.com/gethomepage/homepage.git
synced 2026-10-02 17:01:15 -07:00
Feature: homepage MCP
This commit is contained in:
@@ -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"}'
|
||||||
|
```
|
||||||
@@ -26,6 +26,7 @@ nav:
|
|||||||
- configs/kubernetes.md
|
- configs/kubernetes.md
|
||||||
- configs/docker.md
|
- configs/docker.md
|
||||||
- configs/proxmox.md
|
- configs/proxmox.md
|
||||||
|
- configs/mcp.md
|
||||||
- configs/custom-css-js.md
|
- configs/custom-css-js.md
|
||||||
- "Widgets":
|
- "Widgets":
|
||||||
- widgets/index.md
|
- widgets/index.md
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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/<filename>.");
|
||||||
|
}
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user