mirror of
https://github.com/gethomepage/homepage.git
synced 2026-09-16 09:05:57 -07:00
Feature: homepage MCP (#6771)
Docker CI / Docker Build & Push (push) Has been cancelled
Lint / Linting Checks (push) Has been cancelled
Release Drafter / Update Release Draft (push) Has been cancelled
Release Drafter / Auto Label PR (push) Has been cancelled
Tests / vitest (1) (push) Has been cancelled
Tests / vitest (2) (push) Has been cancelled
Tests / vitest (3) (push) Has been cancelled
Tests / vitest (4) (push) Has been cancelled
Docker CI / Docker Build & Push (push) Has been cancelled
Lint / Linting Checks (push) Has been cancelled
Release Drafter / Update Release Draft (push) Has been cancelled
Release Drafter / Auto Label PR (push) Has been cancelled
Tests / vitest (1) (push) Has been cancelled
Tests / vitest (2) (push) Has been cancelled
Tests / vitest (3) (push) Has been cancelled
Tests / vitest (4) (push) Has been cancelled
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 378 KiB |
@@ -0,0 +1,134 @@
|
||||
---
|
||||
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.
|
||||
|
||||
Once enabled you can use the endpoint to help an assistant understand your current Homepage configuration, add new services or widgets, and get links to relevant documentation. For example, an assistant could add a new media service to your dashboard by appending it to `services.yaml` via the MCP endpoint instead of you having to manually edit the file. For example, you can ask an assistant to add a new service entry for Paperless-ngx:
|
||||
|
||||

|
||||
|
||||
## 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 auth is enabled with `HOMEPAGE_AUTH_ENABLED`, requests from an authenticated Homepage session are allowed.
|
||||
|
||||
For MCP clients that cannot use the browser session, set `HOMEPAGE_MCP_TOKEN`. Requests can then 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_AUTH_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`. |
|
||||
| `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. |
|
||||
|
||||
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:
|
||||
|
||||
```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/docker.md
|
||||
- configs/proxmox.md
|
||||
- configs/mcp.md
|
||||
- configs/custom-css-js.md
|
||||
- "Widgets":
|
||||
- widgets/index.md
|
||||
|
||||
@@ -4,6 +4,13 @@ import { NextResponse } from "next/server";
|
||||
const authEnabled = Boolean(process.env.HOMEPAGE_AUTH_ENABLED);
|
||||
const authSecret = process.env.NEXTAUTH_SECRET || process.env.HOMEPAGE_AUTH_SECRET;
|
||||
|
||||
function hasMcpToken(req) {
|
||||
const token = process.env.HOMEPAGE_MCP_TOKEN;
|
||||
if (!token) return false;
|
||||
|
||||
return req.headers.get("authorization") === `Bearer ${token}` || req.headers.get("x-homepage-mcp-token") === token;
|
||||
}
|
||||
|
||||
export async function middleware(req) {
|
||||
// Check the Host header, if HOMEPAGE_ALLOWED_HOSTS is set
|
||||
const host = req.headers.get("host");
|
||||
@@ -21,6 +28,10 @@ export async function middleware(req) {
|
||||
}
|
||||
|
||||
if (authEnabled && !new URL(req.url).pathname.startsWith("/api/healthcheck")) {
|
||||
if (new URL(req.url).pathname === "/api/mcp" && hasMcpToken(req)) {
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
const token = await getToken({ req, secret: authSecret });
|
||||
if (!token) {
|
||||
const signInUrl = new URL("/auth/signin", req.url);
|
||||
|
||||
+20
-2
@@ -18,11 +18,14 @@ async function loadMiddleware() {
|
||||
return mod.middleware;
|
||||
}
|
||||
|
||||
function createReq(host = "localhost:3000", url = "http://localhost:3000/") {
|
||||
function createReq(host = "localhost:3000", url = "http://localhost:3000/", headers = {}) {
|
||||
return {
|
||||
url,
|
||||
headers: {
|
||||
get: (key) => (key === "host" ? host : null),
|
||||
get: (key) => {
|
||||
if (key === "host") return host;
|
||||
return headers[key] ?? null;
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -127,4 +130,19 @@ describe("middleware", () => {
|
||||
expect(NextResponse.next).toHaveBeenCalled();
|
||||
expect(res).toEqual({ type: "next" });
|
||||
});
|
||||
|
||||
it("allows MCP requests with a bearer token when auth is enabled", async () => {
|
||||
process.env.HOMEPAGE_AUTH_ENABLED = "true";
|
||||
process.env.HOMEPAGE_AUTH_SECRET = "secret";
|
||||
process.env.HOMEPAGE_MCP_TOKEN = "mcp-secret";
|
||||
|
||||
const middleware = await loadMiddleware();
|
||||
const res = await middleware(
|
||||
createReq("localhost:3000", "http://localhost:3000/api/mcp", { authorization: "Bearer mcp-secret" }),
|
||||
);
|
||||
|
||||
expect(getToken).not.toHaveBeenCalled();
|
||||
expect(NextResponse.next).toHaveBeenCalled();
|
||||
expect(res).toEqual({ type: "next" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -91,7 +91,7 @@ if (authEnabled) {
|
||||
}
|
||||
}
|
||||
|
||||
export default NextAuth({
|
||||
export const authOptions = {
|
||||
providers,
|
||||
session: {
|
||||
strategy: "jwt",
|
||||
@@ -111,4 +111,6 @@ export default NextAuth({
|
||||
signOut: async (message) => console.debug("[nextauth][event][signOut]", message),
|
||||
error: async (message) => console.error("[nextauth][event][error]", message),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export default NextAuth(authOptions);
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { getServerSession } from "next-auth/next";
|
||||
|
||||
import { authOptions } from "pages/api/auth/[...nextauth]";
|
||||
import { handleMcpRequest, mcpAuthorized, mcpEnabled } from "utils/mcp/homepage-mcp";
|
||||
|
||||
async function hasHomepageSession(req, res) {
|
||||
if (!process.env.HOMEPAGE_AUTH_ENABLED) return false;
|
||||
return Boolean(await getServerSession(req, res, authOptions));
|
||||
}
|
||||
|
||||
export default async function handler(req, res) {
|
||||
if (!mcpEnabled()) {
|
||||
return res.status(404).end("Not Found");
|
||||
}
|
||||
|
||||
if (!mcpAuthorized(req) && !(await hasHomepageSession(req, res))) {
|
||||
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,165 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { getServerSession } = vi.hoisted(() => ({
|
||||
getServerSession: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("next-auth/next", () => ({ getServerSession }));
|
||||
|
||||
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();
|
||||
getServerSession.mockReset();
|
||||
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("allows requests with a NextAuth session when Homepage auth is enabled", async () => {
|
||||
process.env.HOMEPAGE_MCP_ENABLED = "true";
|
||||
process.env.HOMEPAGE_AUTH_ENABLED = "true";
|
||||
process.env.HOMEPAGE_AUTH_PASSWORD = "password";
|
||||
process.env.HOMEPAGE_AUTH_SECRET = "auth-secret";
|
||||
getServerSession.mockResolvedValueOnce({ user: { name: "Homepage" } });
|
||||
const handler = await loadHandler();
|
||||
const res = mockResponse();
|
||||
|
||||
await handler({ method: "POST", headers: {}, body: { jsonrpc: "2.0", id: 1, method: "tools/list" } }, res);
|
||||
|
||||
expect(getServerSession).toHaveBeenCalled();
|
||||
expect(res.status).toHaveBeenCalledWith(200);
|
||||
expect(res.body.result.tools.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("rejects requests without a token or session when Homepage auth is enabled", async () => {
|
||||
process.env.HOMEPAGE_MCP_ENABLED = "true";
|
||||
process.env.HOMEPAGE_AUTH_ENABLED = "true";
|
||||
process.env.HOMEPAGE_AUTH_PASSWORD = "password";
|
||||
process.env.HOMEPAGE_AUTH_SECRET = "auth-secret";
|
||||
getServerSession.mockResolvedValueOnce(null);
|
||||
const handler = await loadHandler();
|
||||
const res = mockResponse();
|
||||
|
||||
await handler({ method: "POST", headers: {}, body: { jsonrpc: "2.0", id: 1, method: "tools/list" } }, res);
|
||||
|
||||
expect(getServerSession).toHaveBeenCalled();
|
||||
expect(res.status).toHaveBeenCalledWith(401);
|
||||
});
|
||||
|
||||
it("allows bearer token requests when Homepage auth is enabled", async () => {
|
||||
process.env.HOMEPAGE_MCP_ENABLED = "true";
|
||||
process.env.HOMEPAGE_AUTH_ENABLED = "true";
|
||||
process.env.HOMEPAGE_AUTH_PASSWORD = "password";
|
||||
process.env.HOMEPAGE_AUTH_SECRET = "auth-secret";
|
||||
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(getServerSession).not.toHaveBeenCalled();
|
||||
expect(res.status).toHaveBeenCalledWith(200);
|
||||
});
|
||||
|
||||
it("returns 202 for JSON-RPC notifications", async () => {
|
||||
process.env.HOMEPAGE_MCP_ENABLED = "true";
|
||||
const handler = await loadHandler();
|
||||
const res = mockResponse();
|
||||
|
||||
await handler({ method: "POST", headers: {}, body: { jsonrpc: "2.0", method: "notifications/initialized" } }, res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(202);
|
||||
expect(res.end).toHaveBeenCalledWith();
|
||||
});
|
||||
|
||||
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,500 @@
|
||||
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 authEnabled() {
|
||||
return Boolean(process.env.HOMEPAGE_AUTH_ENABLED);
|
||||
}
|
||||
|
||||
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 parseYamlConfig(file) {
|
||||
const parsed = yaml.load(readConfig(file) || "");
|
||||
return parsed ?? [];
|
||||
}
|
||||
|
||||
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 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,
|
||||
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: "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.",
|
||||
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": {
|
||||
const disabled = ensureWriteEnabled();
|
||||
if (disabled) return disabled;
|
||||
|
||||
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 "add_service":
|
||||
return addService(args);
|
||||
case "add_info_widget":
|
||||
return addInfoWidget(args);
|
||||
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 mcpTokenAuthorized(req) {
|
||||
const token = requiredToken();
|
||||
if (!token) return false;
|
||||
|
||||
const authHeader = req.headers.authorization;
|
||||
return authHeader === `Bearer ${token}` || req.headers["x-homepage-mcp-token"] === token;
|
||||
}
|
||||
|
||||
export function mcpAuthorized(req) {
|
||||
return mcpTokenAuthorized(req) || (!requiredToken() && !authEnabled());
|
||||
}
|
||||
|
||||
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,635 @@
|
||||
import { mkdtempSync, readFileSync, writeFileSync } 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");
|
||||
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 () => {
|
||||
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");
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
it("lists config file metadata and resources", async () => {
|
||||
process.env.HOMEPAGE_MCP_ALLOW_WRITE = "true";
|
||||
const configDir = mkdtempSync(path.join(tmpdir(), "homepage-mcp-test-"));
|
||||
writeFileSync(path.join(configDir, "settings.yaml"), "title: Test\n");
|
||||
const mod = await loadMcpWithConfigDir(configDir);
|
||||
|
||||
const filesResponse = mod.handleMcpRequest({
|
||||
jsonrpc: "2.0",
|
||||
id: 14,
|
||||
method: "tools/call",
|
||||
params: { name: "list_config_files" },
|
||||
});
|
||||
const resourcesResponse = mod.handleMcpRequest({ jsonrpc: "2.0", id: 15, method: "resources/list" });
|
||||
|
||||
const files = JSON.parse(filesResponse.result.content[0].text).files;
|
||||
expect(files.find((file) => file.file === "settings.yaml")).toMatchObject({
|
||||
exists: true,
|
||||
writable: true,
|
||||
docs: "https://gethomepage.dev/configs/settings/",
|
||||
});
|
||||
expect(files.find((file) => file.file === "services.yaml").exists).toBe(false);
|
||||
expect(resourcesResponse.result.resources.map((resource) => resource.uri)).toContain(
|
||||
"homepage://config/custom.css",
|
||||
);
|
||||
});
|
||||
|
||||
it("reads missing files and non-YAML resources as empty text", async () => {
|
||||
const mod = await loadMcpWithConfigDir(mkdtempSync(path.join(tmpdir(), "homepage-mcp-test-")));
|
||||
|
||||
const readResponse = mod.handleMcpRequest({
|
||||
jsonrpc: "2.0",
|
||||
id: 16,
|
||||
method: "tools/call",
|
||||
params: {
|
||||
name: "read_config_file",
|
||||
arguments: { file: "custom.css" },
|
||||
},
|
||||
});
|
||||
const resourceResponse = mod.handleMcpRequest({
|
||||
jsonrpc: "2.0",
|
||||
id: 17,
|
||||
method: "resources/read",
|
||||
params: { uri: "homepage://config/custom.css" },
|
||||
});
|
||||
|
||||
expect(readResponse.result.content[0].text).toBe("");
|
||||
expect(resourceResponse.result.contents[0]).toMatchObject({
|
||||
mimeType: "text/plain",
|
||||
text: "",
|
||||
});
|
||||
});
|
||||
|
||||
it("validates non-YAML files and can validate file contents from disk", async () => {
|
||||
const configDir = mkdtempSync(path.join(tmpdir(), "homepage-mcp-test-"));
|
||||
writeFileSync(path.join(configDir, "settings.yaml"), "title: Test\n");
|
||||
const mod = await loadMcpWithConfigDir(configDir);
|
||||
|
||||
const cssResponse = mod.handleMcpRequest({
|
||||
jsonrpc: "2.0",
|
||||
id: 18,
|
||||
method: "tools/call",
|
||||
params: {
|
||||
name: "validate_config_file",
|
||||
arguments: { file: "custom.css", content: "body {" },
|
||||
},
|
||||
});
|
||||
const yamlResponse = mod.handleMcpRequest({
|
||||
jsonrpc: "2.0",
|
||||
id: 19,
|
||||
method: "tools/call",
|
||||
params: {
|
||||
name: "validate_config_file",
|
||||
arguments: { file: "settings.yaml" },
|
||||
},
|
||||
});
|
||||
|
||||
expect(JSON.parse(cssResponse.result.content[0].text)).toEqual({ valid: true });
|
||||
expect(JSON.parse(yamlResponse.result.content[0].text)).toEqual({ valid: true });
|
||||
});
|
||||
|
||||
it("returns JSON-RPC errors for invalid requests and unknown methods", async () => {
|
||||
const mod = await loadMcpWithConfigDir(mkdtempSync(path.join(tmpdir(), "homepage-mcp-test-")));
|
||||
|
||||
const invalidResponse = mod.handleMcpRequest({ id: 20, method: "tools/list" });
|
||||
const unknownMethodResponse = mod.handleMcpRequest({ jsonrpc: "2.0", id: 21, method: "unknown/method" });
|
||||
const notificationResponse = mod.handleMcpRequest({ jsonrpc: "2.0", method: "notifications/initialized" });
|
||||
|
||||
expect(invalidResponse.error).toMatchObject({ code: -32600, message: "Invalid JSON-RPC request" });
|
||||
expect(unknownMethodResponse.error).toMatchObject({ code: -32601, message: "Method not found: unknown/method" });
|
||||
expect(notificationResponse).toBeNull();
|
||||
});
|
||||
|
||||
it("returns JSON-RPC errors for unsupported files, resources, and tools", async () => {
|
||||
const mod = await loadMcpWithConfigDir(mkdtempSync(path.join(tmpdir(), "homepage-mcp-test-")));
|
||||
|
||||
const unsupportedFileResponse = mod.handleMcpRequest({
|
||||
jsonrpc: "2.0",
|
||||
id: 22,
|
||||
method: "tools/call",
|
||||
params: {
|
||||
name: "read_config_file",
|
||||
arguments: { file: "secrets.yaml" },
|
||||
},
|
||||
});
|
||||
const badResourceResponse = mod.handleMcpRequest({
|
||||
jsonrpc: "2.0",
|
||||
id: 23,
|
||||
method: "resources/read",
|
||||
params: { uri: "homepage://unknown/settings.yaml" },
|
||||
});
|
||||
const unknownToolResponse = mod.handleMcpRequest({
|
||||
jsonrpc: "2.0",
|
||||
id: 24,
|
||||
method: "tools/call",
|
||||
params: { name: "missing_tool" },
|
||||
});
|
||||
|
||||
expect(unsupportedFileResponse.error).toMatchObject({ code: -32602 });
|
||||
expect(unsupportedFileResponse.error.message).toContain("Unsupported config file");
|
||||
expect(badResourceResponse.error).toMatchObject({
|
||||
code: -32602,
|
||||
message: "Unsupported resource URI. Use homepage://config/<filename>.",
|
||||
});
|
||||
expect(unknownToolResponse.error).toMatchObject({ code: -32602, message: "Unknown tool 'missing_tool'" });
|
||||
});
|
||||
|
||||
it("rejects invalid write_config_file arguments and YAML", async () => {
|
||||
process.env.HOMEPAGE_MCP_ALLOW_WRITE = "true";
|
||||
const mod = await loadMcpWithConfigDir(mkdtempSync(path.join(tmpdir(), "homepage-mcp-test-")));
|
||||
|
||||
const nonStringResponse = mod.handleMcpRequest({
|
||||
jsonrpc: "2.0",
|
||||
id: 25,
|
||||
method: "tools/call",
|
||||
params: {
|
||||
name: "write_config_file",
|
||||
arguments: { file: "settings.yaml", content: { title: "Test" } },
|
||||
},
|
||||
});
|
||||
const invalidYamlResponse = mod.handleMcpRequest({
|
||||
jsonrpc: "2.0",
|
||||
id: 26,
|
||||
method: "tools/call",
|
||||
params: {
|
||||
name: "write_config_file",
|
||||
arguments: { file: "settings.yaml", content: "title: [" },
|
||||
},
|
||||
});
|
||||
|
||||
expect(nonStringResponse.error).toMatchObject({ code: -32602, message: "content must be a string" });
|
||||
expect(invalidYamlResponse.result.isError).toBe(true);
|
||||
expect(JSON.parse(invalidYamlResponse.result.content[0].text).valid).toBe(false);
|
||||
});
|
||||
|
||||
it("adds a service to an existing group", 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: https://jellyfin.example.com\n",
|
||||
);
|
||||
const mod = await loadMcpWithConfigDir(configDir);
|
||||
|
||||
const response = mod.handleMcpRequest({
|
||||
jsonrpc: "2.0",
|
||||
id: 27,
|
||||
method: "tools/call",
|
||||
params: {
|
||||
name: "add_service",
|
||||
arguments: {
|
||||
group: "Media",
|
||||
name: "Plex",
|
||||
service: { href: "https://plex.example.com" },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.result.isError).toBeUndefined();
|
||||
expect(readFileSync(path.join(configDir, "services.yaml"), "utf8")).toContain(" - Jellyfin:");
|
||||
expect(readFileSync(path.join(configDir, "services.yaml"), "utf8")).toContain(" - Plex:");
|
||||
});
|
||||
|
||||
it("rejects invalid add_service arguments and existing file shapes", async () => {
|
||||
process.env.HOMEPAGE_MCP_ALLOW_WRITE = "true";
|
||||
const invalidArgsMod = await loadMcpWithConfigDir(mkdtempSync(path.join(tmpdir(), "homepage-mcp-test-")));
|
||||
|
||||
expect(
|
||||
invalidArgsMod.handleMcpRequest({
|
||||
jsonrpc: "2.0",
|
||||
id: 28,
|
||||
method: "tools/call",
|
||||
params: { name: "add_service", arguments: { group: "", name: "Plex" } },
|
||||
}).error.message,
|
||||
).toBe("group must be a non-empty string");
|
||||
expect(
|
||||
invalidArgsMod.handleMcpRequest({
|
||||
jsonrpc: "2.0",
|
||||
id: 29,
|
||||
method: "tools/call",
|
||||
params: { name: "add_service", arguments: { group: "Media", name: " " } },
|
||||
}).error.message,
|
||||
).toBe("name must be a non-empty string");
|
||||
expect(
|
||||
invalidArgsMod.handleMcpRequest({
|
||||
jsonrpc: "2.0",
|
||||
id: 30,
|
||||
method: "tools/call",
|
||||
params: { name: "add_service", arguments: { group: "Media", name: "Plex", service: [] } },
|
||||
}).error.message,
|
||||
).toBe("service must be an object");
|
||||
|
||||
const invalidYamlDir = mkdtempSync(path.join(tmpdir(), "homepage-mcp-test-"));
|
||||
writeFileSync(path.join(invalidYamlDir, "services.yaml"), "- Media:\n - Broken: [");
|
||||
const invalidYamlMod = await loadMcpWithConfigDir(invalidYamlDir);
|
||||
const invalidYamlResponse = invalidYamlMod.handleMcpRequest({
|
||||
jsonrpc: "2.0",
|
||||
id: 31,
|
||||
method: "tools/call",
|
||||
params: { name: "add_service", arguments: { group: "Media", name: "Plex" } },
|
||||
});
|
||||
expect(invalidYamlResponse.result.isError).toBe(true);
|
||||
|
||||
const nonArrayDir = mkdtempSync(path.join(tmpdir(), "homepage-mcp-test-"));
|
||||
writeFileSync(path.join(nonArrayDir, "services.yaml"), "Media: []\n");
|
||||
const nonArrayMod = await loadMcpWithConfigDir(nonArrayDir);
|
||||
expect(
|
||||
nonArrayMod.handleMcpRequest({
|
||||
jsonrpc: "2.0",
|
||||
id: 32,
|
||||
method: "tools/call",
|
||||
params: { name: "add_service", arguments: { group: "Media", name: "Plex" } },
|
||||
}).error.message,
|
||||
).toBe("services.yaml must contain a top-level array");
|
||||
|
||||
const nonArrayGroupDir = mkdtempSync(path.join(tmpdir(), "homepage-mcp-test-"));
|
||||
writeFileSync(path.join(nonArrayGroupDir, "services.yaml"), "- Media: {}\n");
|
||||
const nonArrayGroupMod = await loadMcpWithConfigDir(nonArrayGroupDir);
|
||||
expect(
|
||||
nonArrayGroupMod.handleMcpRequest({
|
||||
jsonrpc: "2.0",
|
||||
id: 33,
|
||||
method: "tools/call",
|
||||
params: { name: "add_service", arguments: { group: "Media", name: "Plex" } },
|
||||
}).error.message,
|
||||
).toBe("Group 'Media' must contain an array");
|
||||
});
|
||||
|
||||
it("rejects invalid add_info_widget arguments and existing file shapes", async () => {
|
||||
process.env.HOMEPAGE_MCP_ALLOW_WRITE = "true";
|
||||
const invalidArgsMod = await loadMcpWithConfigDir(mkdtempSync(path.join(tmpdir(), "homepage-mcp-test-")));
|
||||
|
||||
expect(
|
||||
invalidArgsMod.handleMcpRequest({
|
||||
jsonrpc: "2.0",
|
||||
id: 34,
|
||||
method: "tools/call",
|
||||
params: { name: "add_info_widget", arguments: { type: "" } },
|
||||
}).error.message,
|
||||
).toBe("type must be a non-empty string");
|
||||
expect(
|
||||
invalidArgsMod.handleMcpRequest({
|
||||
jsonrpc: "2.0",
|
||||
id: 35,
|
||||
method: "tools/call",
|
||||
params: { name: "add_info_widget", arguments: { type: "resources", options: [] } },
|
||||
}).error.message,
|
||||
).toBe("options must be an object");
|
||||
|
||||
const invalidYamlDir = mkdtempSync(path.join(tmpdir(), "homepage-mcp-test-"));
|
||||
writeFileSync(path.join(invalidYamlDir, "widgets.yaml"), "- resources: [");
|
||||
const invalidYamlMod = await loadMcpWithConfigDir(invalidYamlDir);
|
||||
const invalidYamlResponse = invalidYamlMod.handleMcpRequest({
|
||||
jsonrpc: "2.0",
|
||||
id: 36,
|
||||
method: "tools/call",
|
||||
params: { name: "add_info_widget", arguments: { type: "resources" } },
|
||||
});
|
||||
expect(invalidYamlResponse.result.isError).toBe(true);
|
||||
|
||||
const nonArrayDir = mkdtempSync(path.join(tmpdir(), "homepage-mcp-test-"));
|
||||
writeFileSync(path.join(nonArrayDir, "widgets.yaml"), "resources: {}\n");
|
||||
const nonArrayMod = await loadMcpWithConfigDir(nonArrayDir);
|
||||
expect(
|
||||
nonArrayMod.handleMcpRequest({
|
||||
jsonrpc: "2.0",
|
||||
id: 37,
|
||||
method: "tools/call",
|
||||
params: { name: "add_info_widget", arguments: { type: "resources" } },
|
||||
}).error.message,
|
||||
).toBe("widgets.yaml must contain a top-level array");
|
||||
});
|
||||
|
||||
it("adds an info widget with default options", 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: 38,
|
||||
method: "tools/call",
|
||||
params: { name: "add_info_widget", arguments: { type: "resources" } },
|
||||
});
|
||||
|
||||
expect(response.result.isError).toBeUndefined();
|
||||
expect(readFileSync(path.join(configDir, "widgets.yaml"), "utf8")).toBe("- resources: {}\n");
|
||||
});
|
||||
|
||||
it("returns documentation links with default and fallback topics", async () => {
|
||||
const mod = await loadMcpWithConfigDir(mkdtempSync(path.join(tmpdir(), "homepage-mcp-test-")));
|
||||
|
||||
const defaultResponse = mod.handleMcpRequest({
|
||||
jsonrpc: "2.0",
|
||||
id: 39,
|
||||
method: "tools/call",
|
||||
params: { name: "homepage_docs" },
|
||||
});
|
||||
const fallbackResponse = mod.handleMcpRequest({
|
||||
jsonrpc: "2.0",
|
||||
id: 40,
|
||||
method: "tools/call",
|
||||
params: { name: "homepage_docs", arguments: { topic: "not-real" } },
|
||||
});
|
||||
const fileResponse = mod.handleMcpRequest({
|
||||
jsonrpc: "2.0",
|
||||
id: 41,
|
||||
method: "tools/call",
|
||||
params: { name: "homepage_docs", arguments: { topic: "services.yaml" } },
|
||||
});
|
||||
|
||||
expect(JSON.parse(defaultResponse.result.content[0].text)).toEqual({
|
||||
topic: "overview",
|
||||
url: "https://gethomepage.dev/configs/",
|
||||
});
|
||||
expect(JSON.parse(fallbackResponse.result.content[0].text)).toEqual({
|
||||
topic: "not-real",
|
||||
url: "https://gethomepage.dev/configs/",
|
||||
});
|
||||
expect(JSON.parse(fileResponse.result.content[0].text)).toEqual({
|
||||
topic: "services.yaml",
|
||||
url: "https://gethomepage.dev/configs/services/",
|
||||
});
|
||||
});
|
||||
|
||||
it("checks MCP token and auth mode authorization", async () => {
|
||||
const mod = await loadMcpWithConfigDir(mkdtempSync(path.join(tmpdir(), "homepage-mcp-test-")));
|
||||
|
||||
expect(mod.mcpTokenAuthorized({ headers: {} })).toBe(false);
|
||||
expect(mod.mcpAuthorized({ headers: {} })).toBe(true);
|
||||
|
||||
process.env.HOMEPAGE_AUTH_ENABLED = "true";
|
||||
expect(mod.mcpAuthorized({ headers: {} })).toBe(false);
|
||||
|
||||
process.env.HOMEPAGE_MCP_TOKEN = "secret";
|
||||
expect(mod.mcpTokenAuthorized({ headers: { authorization: "Bearer secret" } })).toBe(true);
|
||||
expect(mod.mcpTokenAuthorized({ headers: { "x-homepage-mcp-token": "secret" } })).toBe(true);
|
||||
expect(mod.mcpTokenAuthorized({ headers: { authorization: "Bearer wrong" } })).toBe(false);
|
||||
expect(mod.mcpAuthorized({ headers: { authorization: "Bearer secret" } })).toBe(true);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user