mirror of
https://github.com/gethomepage/homepage.git
synced 2026-09-25 21:41:17 -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:
@@ -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