feat: migrate MCP server to v2

This commit is contained in:
ajnart committed 2026-08-13 19:56:55 +02:00
1 parent aab12bbe64
commit e4cb8da5e2
17 files changed
+720 -469

No files matched your search

@@ -22,6 +22,7 @@ export async function GET() {
code_challenge_methods_supported: ["S256"],
token_endpoint_auth_methods_supported: ["none"],
scopes_supported: ["mcp:tools"],
resource_indicators_supported: true,
},
{ headers: corsHeaders },
);
@@ -13,7 +13,7 @@ export async function GET() {
return Response.json(
{
resource: baseUrl,
resource: `${baseUrl}/api/mcp`,
authorization_servers: [baseUrl],
},
{ headers: corsHeaders },
@@ -63,7 +63,7 @@ export function McpInstructions({ baseUrl, hasApiKeys, toolGroups }: McpInstruct
await revalidatePathActionAsync("/manage/tools/api");
},
});
const mcpUrl = `${baseUrl}/api/mcp/mcp`;
const mcpUrl = `${baseUrl}/api/mcp`;
const streamableHttpConfig = JSON.stringify(
{
@@ -93,7 +93,7 @@ export function McpInstructions({ baseUrl, hasApiKeys, toolGroups }: McpInstruct
2,
);
const testCommand = `curl -s -X POST -H "Content-Type: application/json" -H "Accept: application/json, text/event-stream" -H "ApiKey: <your-api-key>" -d '{"jsonrpc":"2.0","method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}},"id":1}' ${mcpUrl}`;
const testCommand = `curl -s -X POST -H "Content-Type: application/json" -H "Accept: application/json" -H "ApiKey: <your-api-key>" -H "Mcp-Method: server/discover" -H "Mcp-Name: homarr" -d '{"jsonrpc":"2.0","method":"server/discover","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientInfo":{"name":"test","version":"1.0"},"io.modelcontextprotocol/clientCapabilities":{}}},"id":1}' ${mcpUrl}`;
return (
<Stack gap="xl" p="md">
@@ -31,6 +31,8 @@ vi.mock("@homarr/custom-widgets/authoring-prompt", () => ({ buildCustomWidgetMcp
vi.mock("@homarr/custom-widgets/authoring-resources", () => ({
getCustomWidgetComponent: mocks.getComponent,
getCustomWidgetComponentCatalog: () => ({ components: [{ name: "Text Input" }] }),
getCustomWidgetExample: (name: string) =>
name === "status-card" ? { id: "status-card", title: "Status card", template: "<Text>Status</Text>" } : null,
getCustomWidgetSkillContent: () => "# Custom Widget Skill",
}));
vi.mock("@homarr/custom-widgets/core", () => ({
@@ -220,13 +222,16 @@ describe("non-admin MCP discovery", () => {
]);
const prompts = await callMcp("prompts/list");
expect(prompts.body.result).toEqual({ prompts: [] });
expect(prompts.body.result).toBeUndefined();
expect(prompts.body.error).toBeDefined();
const resources = await callMcp("resources/list");
expect(resources.body.result).toEqual({ resources: [] });
expect(resources.body.result).toBeUndefined();
expect(resources.body.error).toBeDefined();
const templates = await callMcp("resources/templates/list");
expect(templates.body.result).toEqual({ resourceTemplates: [] });
expect(templates.body.result).toBeUndefined();
expect(templates.body.error).toBeDefined();
const prompt = await callMcp("prompts/get", { name: "homarr-custom-widget-author" });
expect(prompt.body.result).toBeUndefined();
@@ -1,443 +1,3 @@
import { AsyncLocalStorage } from "async_hooks";
import type { NextRequest } from "next/server";
import { userAgent } from "next/server";
import { createMcpHandler } from "mcp-handler";
import type { McpTool } from "trpc-to-mcp";
import {
CallToolRequestSchema,
GetPromptRequestSchema,
ListPromptsRequestSchema,
ListResourcesRequestSchema,
ListResourceTemplatesRequestSchema,
ListToolsRequestSchema,
ReadResourceRequestSchema,
} from "@modelcontextprotocol/sdk/types.js";
import { handleMcpRequest } from "../_handler";
import { createTRPCContext, mcpRouter } from "@homarr/api/mcp";
import { API_KEY_HEADER_NAME, getSessionFromApiKeyAsync } from "@homarr/auth/api-key";
import { extractBaseUrlFromHeaders } from "@homarr/common";
import { ipAddressFromHeaders } from "@homarr/common/server";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { buildCustomWidgetMcpPrompt } from "@homarr/custom-widgets/authoring-prompt";
import {
getCustomWidgetComponent,
getCustomWidgetComponentCatalog,
getCustomWidgetSkillContent,
} from "@homarr/custom-widgets/authoring-resources";
import { customJsxExamples, getCustomWidgetJsonSchema } from "@homarr/custom-widgets/core";
import { db } from "@homarr/db";
import { getPackageVersion } from "~/versions/package-reader";
import { extractMcpTools } from "../_extract-tools";
const logger = createLogger({ module: "mcpRoute" });
const AUTH_RATE_WINDOW_MS = 60_000;
const AUTH_MAX_FAILURES = 10;
interface RateLimitStore {
authFailures: Map<string, { count: number; resetAt: number }>;
cleanupTimer: ReturnType<typeof setInterval> | null;
}
declare global {
var mcpRateLimit: RateLimitStore | undefined;
}
const rateLimitStore =
globalThis.mcpRateLimit ??
(globalThis.mcpRateLimit = {
authFailures: new Map(),
cleanupTimer: null,
});
const authFailures = rateLimitStore.authFailures;
if (!rateLimitStore.cleanupTimer) {
rateLimitStore.cleanupTimer = setInterval(() => {
const now = Date.now();
for (const [key, entry] of authFailures) {
if (entry.resetAt < now) authFailures.delete(key);
}
}, AUTH_RATE_WINDOW_MS);
rateLimitStore.cleanupTimer.unref();
}
function checkAuthRateLimit(ip: string | null): boolean {
const key = ip ?? "unknown";
const now = Date.now();
const entry = authFailures.get(key);
if (!entry || entry.resetAt < now) return true;
return entry.count < AUTH_MAX_FAILURES;
}
function recordAuthFailure(ip: string | null) {
const key = ip ?? "unknown";
const now = Date.now();
const entry = authFailures.get(key);
if (!entry || entry.resetAt < now) {
authFailures.set(key, { count: 1, resetAt: now + AUTH_RATE_WINDOW_MS });
return;
}
entry.count++;
}
function clearAuthFailures(ip: string | null) {
authFailures.delete(ip ?? "unknown");
}
interface McpCallerContext {
caller: ReturnType<typeof mcpRouter.createCaller>;
isAdmin: boolean;
}
const callerStorage = new AsyncLocalStorage<McpCallerContext>();
const emptyObjectSchema = {
type: "object" as const,
properties: {},
required: [] as string[],
};
let toolsCache: McpTool[] | null = null;
function normalizeSchema(schema: McpTool["inputSchema"]) {
if (!schema) return emptyObjectSchema;
const properties = (schema.properties ?? {}) as Record<string, Record<string, unknown>>;
const required = ((schema.required as string[]) ?? []).filter((key) => !("default" in (properties[key] ?? {})));
return { type: schema.type ?? ("object" as const), properties, required };
}
function getTools() {
if (!toolsCache) {
toolsCache = extractMcpTools().map((tool) => ({
...tool,
inputSchema: normalizeSchema(tool.inputSchema),
}));
logger.info(`Extracted ${toolsCache.length} MCP tools`);
}
return toolsCache;
}
function getVisibleTools() {
const tools = getTools();
if (!isCustomWidgetMcpEnabled()) {
return tools.filter((tool) => !tool.name.startsWith("customWidget_"));
}
return tools;
}
function requireCustomWidgetMcpAdmin() {
if (!isCustomWidgetMcpEnabled()) throw new Error("Resource not found");
}
const isCustomWidgetMcpEnabled = () => Boolean(callerStorage.getStore()?.isAdmin);
const SAFE_ERROR_MESSAGES: Record<string, string> = {
UNAUTHORIZED: "You do not have permission to perform this action",
FORBIDDEN: "Access denied",
NOT_FOUND: "The requested resource was not found",
BAD_REQUEST: "Invalid request parameters",
INTERNAL_SERVER_ERROR: "An internal error occurred",
TIMEOUT: "The operation timed out",
TOO_MANY_REQUESTS: "Rate limit exceeded, try again later",
CONFLICT: "A conflict occurred with the current state",
PRECONDITION_FAILED: "A precondition for this operation was not met",
};
function sanitizeErrorMessage(error: unknown): string {
if (error instanceof Error && "code" in error) {
const trpcCode = (error as Error & { code: string }).code;
const safeMessage = SAFE_ERROR_MESSAGES[trpcCode];
if (safeMessage) return safeMessage;
}
if (error instanceof Error && error.message.length < 200 && !error.message.includes("/")) {
return error.message;
}
return "Tool execution failed";
}
const SERVER_INSTRUCTIONS = `You are connected to Homarr, a self-hosted homelab dashboard.
## Key Concepts
**Integrations** are connections to external self-hosted services (Sonarr, Radarr, Plex, Overseerr, Pi-hole, Home Assistant, etc.). Each integration has an \`id\` (integrationId) and a \`kind\` (e.g. "sonarr", "radarr", "overseerr"). Use \`integration_all\` to list them. Many tools require an \`integrationId\` — always fetch it from \`integration_all\` or \`integration_search\` first.
**Boards** are customizable dashboards containing widgets that display data from integrations. Users can have multiple boards.
**Apps** are links/bookmarks to self-hosted services displayed on boards. They are separate from integrations — an app is a visual shortcut, an integration is a data connection.
**Permissions**: Each integration result includes a \`permissions\` object:
- \`hasUseAccess: true\` means you can read data from this integration
- \`hasInteractAccess: true\` means you can perform actions (start/stop, request media, etc.)
- \`hasFullAccess: true\` means you can modify/delete the integration config
If \`hasUseAccess\` is false, the API key owner lacks permission for that integration — this is normal, not an error. Simply skip integrations where you lack the required permission level.
## Common Workflows
**"What's coming this week?"** → Use \`calendar_findAllEvents\` to get upcoming media releases from Sonarr (TV), Radarr (movies), Lidarr (music), and Readarr (books).
**"Search and request a movie/show"** → Use \`integration_searchMediaRequests\` to search across Overseerr/Jellyseerr, then \`integration_requestMedia\` to submit a request. Use \`integration_getMediaRequestOptions\` first for TV shows to pick seasons.
**"What's streaming right now?"** → Use \`mediaServer_getCurrentStreams\` to see active Plex/Jellyfin/Emby streams.
**"Check server health"** → Use \`healthMonitoring_getSystemHealthStatus\` for NAS/server metrics or \`healthMonitoring_getClusterHealthStatus\` for Proxmox clusters.
**"Check Beszel systems"** → Call \`integration_all\` to get Beszel integrationIds, then use \`beszel_getSystems\` to list all monitored systems with CPU/memory/disk/network status. Use \`beszel_getAlerts\` for active alerts and history. Use \`beszel_getSystemStats\` for historical metrics of a specific system (requires systemId from \`beszel_getSystems\`).
**"Manage Docker containers"** → Use \`docker_getContainers\` to list containers with status/CPU/memory, then \`docker_startAll\`/\`docker_stopAll\`/\`docker_restartAll\` with container IDs.
**"Control smart home"** → Use \`smartHome_entityState\` to check a Home Assistant entity, \`smartHome_switchEntity\` to toggle it, or \`smartHome_executeAutomation\` to trigger an automation.
**"Block/unblock ads"** → Use \`dnsHole_summary\` for Pi-hole/AdGuard stats, \`dnsHole_disable\` to temporarily disable blocking, \`dnsHole_enable\` to re-enable.
**"Check downloads"** → Use \`downloads_getJobsAndStatuses\` for queue status, \`downloads_pause\`/\`downloads_resume\` to control queues.
**"Pending media requests"** → Use \`mediaRequests_getLatestRequests\` to see pending/approved/declined requests, \`mediaRequests_answerRequest\` to approve or decline.
## Tips
- Always call \`integration_all\` first to discover available integrations and their IDs before using integration-dependent tools.
- The \`integrationId\` parameter in widget tools accepts the ID from \`integration_all\`, not the integration name or kind.
- If a tool returns empty results, the user may not have the required integration configured.
- Docker and Kubernetes tools work only if the Homarr instance has access to the Docker socket or Kubernetes cluster.`;
const mcpHandler = createMcpHandler(
(server) => {
server.server.setRequestHandler(ListPromptsRequestSchema, () => ({
prompts: isCustomWidgetMcpEnabled()
? [
{
name: "homarr-custom-widget-author",
description: "Author and iterate on one Homarr Custom JSX v2 widget.",
arguments: [
{ name: "request", description: "The widget the user wants.", required: false },
{ name: "documentationUrl", description: "External API documentation URL.", required: false },
],
},
]
: [],
}));
server.server.setRequestHandler(GetPromptRequestSchema, (request) => {
requireCustomWidgetMcpAdmin();
if (request.params.name !== "homarr-custom-widget-author") throw new Error("Prompt not found");
return {
description: "Current Homarr Custom Widget authoring workflow.",
messages: [
{
role: "user" as const,
content: {
type: "text" as const,
text: buildCustomWidgetMcpPrompt(
request.params.arguments?.request,
request.params.arguments?.documentationUrl,
),
},
},
],
};
});
server.server.setRequestHandler(ListResourcesRequestSchema, () => ({
resources: isCustomWidgetMcpEnabled()
? [
{
uri: "homarr://custom-widgets/schema",
name: "Custom Widget schema",
mimeType: "application/schema+json",
},
{
uri: "homarr://custom-widgets/components",
name: "Custom Widget components",
mimeType: "application/json",
},
{ uri: "homarr://custom-widgets/skill", name: "Custom Widget skill", mimeType: "text/markdown" },
...customJsxExamples.map((example) => ({
uri: `homarr://custom-widgets/examples/${example.id}`,
name: `Custom Widget example: ${example.title}`,
mimeType: "application/json",
})),
]
: [],
}));
server.server.setRequestHandler(ListResourceTemplatesRequestSchema, () => ({
resourceTemplates: isCustomWidgetMcpEnabled()
? [
{
uriTemplate: "homarr://custom-widgets/components/{name}",
name: "Custom Widget component",
description: "Metadata for one installed Custom Widget component.",
mimeType: "application/json",
},
{
uriTemplate: "homarr://custom-widgets/examples/{name}",
name: "Custom Widget example",
description: "One canonical Custom Widget example.",
mimeType: "application/json",
},
]
: [],
}));
server.server.setRequestHandler(ReadResourceRequestSchema, (request) => {
requireCustomWidgetMcpAdmin();
const { uri } = request.params;
let text: string;
let mimeType = "application/json";
if (uri === "homarr://custom-widgets/schema") {
text = JSON.stringify(getCustomWidgetJsonSchema());
mimeType = "application/schema+json";
} else if (uri === "homarr://custom-widgets/components") text = JSON.stringify(getCustomWidgetComponentCatalog());
else if (uri === "homarr://custom-widgets/skill") {
text = getCustomWidgetSkillContent();
mimeType = "text/markdown";
} else if (uri.startsWith("homarr://custom-widgets/components/")) {
const name = decodeURIComponent(uri.slice("homarr://custom-widgets/components/".length));
const component = getCustomWidgetComponent(name);
if (!component) throw new Error("Component resource not found");
text = JSON.stringify(component);
} else if (uri.startsWith("homarr://custom-widgets/examples/")) {
const name = decodeURIComponent(uri.slice("homarr://custom-widgets/examples/".length));
const example = customJsxExamples.find((candidate) => candidate.id === name);
if (!example) throw new Error("Example resource not found");
text = JSON.stringify(example);
} else throw new Error("Resource not found");
return { contents: [{ uri, mimeType, text }] };
});
server.server.setRequestHandler(ListToolsRequestSchema, () => ({
tools: getVisibleTools(),
}));
server.server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params;
const tools = getVisibleTools();
const tool = tools.find((t) => t.name === name);
if (!tool) {
return {
content: [{ type: "text" as const, text: `Tool "${name}" not found` }],
isError: true,
};
}
const callerContext = callerStorage.getStore();
if (!callerContext) {
return {
content: [{ type: "text" as const, text: "Authentication context not available" }],
isError: true,
};
}
try {
const procedure = tool.pathInRouter.reduce<any>((acc, part) => acc?.[part], callerContext.caller);
if (typeof procedure !== "function") {
return {
content: [{ type: "text" as const, text: `Tool "${name}" is not callable` }],
isError: true,
};
}
const input = args && Object.keys(args).length > 0 ? args : undefined;
const result = await procedure(input);
return {
content: [{ type: "text" as const, text: JSON.stringify(result) }],
};
} catch (error) {
const message = sanitizeErrorMessage(error);
logger.warn("MCP tool execution failed", {
tool: name,
errorName: error instanceof Error ? error.name : "UnknownError",
errorCode:
error instanceof Error && "code" in error && typeof error.code === "string" ? error.code : undefined,
});
return {
content: [{ type: "text" as const, text: JSON.stringify({ error: message }) }],
isError: true,
};
}
});
},
{
capabilities: { tools: {}, prompts: {}, resources: {} },
instructions: SERVER_INSTRUCTIONS,
serverInfo: {
name: "homarr",
version: getPackageVersion(),
},
},
{
basePath: "/api/mcp",
maxDuration: 60,
},
);
function extractApiKeyValue(req: NextRequest): string | null {
const apiKeyHeader = req.headers.get(API_KEY_HEADER_NAME);
if (apiKeyHeader) return apiKeyHeader;
const authHeader = req.headers.get("Authorization");
if (authHeader?.startsWith("Bearer ")) return authHeader.slice(7);
return null;
}
function jsonErrorResponse(status: number, body: Record<string, string>, extraHeaders: Record<string, string> = {}) {
return new Response(JSON.stringify(body), {
status,
headers: { "Content-Type": "application/json", ...extraHeaders },
});
}
const handler = async (req: NextRequest) => {
const apiKeyValue = extractApiKeyValue(req);
const ipAddress = ipAddressFromHeaders(req.headers);
const { ua } = userAgent(req);
if (!checkAuthRateLimit(ipAddress)) {
return jsonErrorResponse(
429,
{ error: "rate_limited", hint: "Too many failed authentication attempts. Try again later." },
{ "Retry-After": "60" },
);
}
if (!apiKeyValue) {
recordAuthFailure(ipAddress);
const baseUrl = extractBaseUrlFromHeaders(req.headers);
return jsonErrorResponse(
401,
{
error: "unauthorized",
hint: "Authenticate with an ApiKey header or via OAuth at /.well-known/oauth-authorization-server",
},
{ "WWW-Authenticate": `Bearer resource_metadata="${baseUrl}/.well-known/oauth-protected-resource"` },
);
}
if (!apiKeyValue.includes(".")) {
recordAuthFailure(ipAddress);
return jsonErrorResponse(401, { error: "invalid_token", hint: "The token must be in the format '<id>.<token>'." });
}
const session = await getSessionFromApiKeyAsync(db, apiKeyValue, ipAddress, ua);
if (!session) {
recordAuthFailure(ipAddress);
return jsonErrorResponse(401, {
error: "invalid_token",
hint: "The API key was not found or the token is incorrect.",
});
}
clearAuthFailures(ipAddress);
logger.info("MCP request authenticated", { userId: session.user.id });
const ctx = createTRPCContext({ session, headers: req.headers });
const caller = mcpRouter.createCaller(ctx);
return callerStorage.run({ caller, isAdmin: session.user.permissions.includes("admin") }, () =>
mcpHandler(req as unknown as Request),
);
};
export { handler as GET, handler as POST, handler as DELETE };
export { handleMcpRequest as DELETE, handleMcpRequest as GET, handleMcpRequest as POST };
+3 -15
View File
@@ -1,23 +1,11 @@
import { extractToolsFromProcedures } from "trpc-to-mcp";
import { extractMcpToolsFromProcedures, mcpRouter } from "@homarr/api/mcp";
import { mcpRouter } from "@homarr/api/mcp";
let cache: ReturnType<typeof extractToolsFromProcedures> | null = null;
let cache: ReturnType<typeof extractMcpToolsFromProcedures> | null = null;
export function extractMcpTools() {
if (cache) return cache;
const originalWarn = console.warn;
console.warn = (...args: unknown[]) => {
if (typeof args[0] === "string" && args[0].includes("[TRPC-TO-MCP]")) return;
originalWarn.apply(console, args);
};
try {
cache = extractToolsFromProcedures(mcpRouter);
} finally {
console.warn = originalWarn;
}
cache = extractMcpToolsFromProcedures(mcpRouter);
return cache;
}
+360
View File
@@ -0,0 +1,360 @@
import type { NextRequest } from "next/server";
import { userAgent } from "next/server";
import { ResourceTemplate, type McpServer } from "@modelcontextprotocol/server";
import { z } from "zod/v4";
import type { McpTool } from "@homarr/api/mcp";
import { createTRPCContext, mcpRouter } from "@homarr/api/mcp";
import { API_KEY_HEADER_NAME, getSessionFromApiKeyAsync } from "@homarr/auth/api-key";
import { extractBaseUrlFromHeaders } from "@homarr/common";
import { ipAddressFromHeaders } from "@homarr/common/server";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { buildCustomWidgetMcpPrompt } from "@homarr/custom-widgets/authoring-prompt";
import {
getCustomWidgetComponent,
getCustomWidgetComponentCatalog,
getCustomWidgetExample,
getCustomWidgetSkillContent,
} from "@homarr/custom-widgets/authoring-resources";
import { customJsxExamples, getCustomWidgetJsonSchema } from "@homarr/custom-widgets/core";
import { db } from "@homarr/db";
import { getPackageVersion } from "~/versions/package-reader";
import { extractMcpTools } from "./_extract-tools";
import { createMcpProtocolHandler } from "./_protocol";
const logger = createLogger({ module: "mcpRoute" });
const AUTH_RATE_WINDOW_MS = 60_000;
const AUTH_MAX_FAILURES = 10;
interface RateLimitStore {
authFailures: Map<string, { count: number; resetAt: number }>;
cleanupTimer: ReturnType<typeof setInterval> | null;
}
declare global {
var mcpRateLimit: RateLimitStore | undefined;
}
const rateLimitStore =
globalThis.mcpRateLimit ??
(globalThis.mcpRateLimit = {
authFailures: new Map(),
cleanupTimer: null,
});
const authFailures = rateLimitStore.authFailures;
if (!rateLimitStore.cleanupTimer) {
rateLimitStore.cleanupTimer = setInterval(() => {
const now = Date.now();
for (const [key, entry] of authFailures) {
if (entry.resetAt < now) authFailures.delete(key);
}
}, AUTH_RATE_WINDOW_MS);
rateLimitStore.cleanupTimer.unref();
}
function checkAuthRateLimit(ip: string | null): boolean {
const key = ip ?? "unknown";
const now = Date.now();
const entry = authFailures.get(key);
if (!entry || entry.resetAt < now) return true;
return entry.count < AUTH_MAX_FAILURES;
}
function recordAuthFailure(ip: string | null) {
const key = ip ?? "unknown";
const now = Date.now();
const entry = authFailures.get(key);
if (!entry || entry.resetAt < now) {
authFailures.set(key, { count: 1, resetAt: now + AUTH_RATE_WINDOW_MS });
return;
}
entry.count++;
}
function clearAuthFailures(ip: string | null) {
authFailures.delete(ip ?? "unknown");
}
let toolsCache: McpTool[] | null = null;
function getTools() {
if (!toolsCache) {
toolsCache = extractMcpTools();
logger.info(`Extracted ${toolsCache.length} MCP tools`);
}
return toolsCache;
}
const getVisibleTools = (isAdmin: boolean) =>
isAdmin ? getTools() : getTools().filter((tool) => !tool.name.startsWith("customWidget_"));
function registerCustomWidgetAuthoring(server: McpServer) {
server.registerPrompt(
"homarr-custom-widget-author",
{
description: "Author and iterate on one Homarr Custom JSX v2 widget.",
argsSchema: z.object({
request: z.string().optional().describe("The widget the user wants."),
documentationUrl: z.string().optional().describe("External API documentation URL."),
}),
},
({ request, documentationUrl }) => ({
description: "Current Homarr Custom Widget authoring workflow.",
messages: [
{
role: "user" as const,
content: {
type: "text" as const,
text: buildCustomWidgetMcpPrompt(request, documentationUrl),
},
},
],
}),
);
server.registerResource(
"Custom Widget schema",
"homarr://custom-widgets/schema",
{ mimeType: "application/schema+json" },
(uri) => ({
contents: [
{
uri: uri.href,
mimeType: "application/schema+json",
text: JSON.stringify(getCustomWidgetJsonSchema()),
},
],
}),
);
server.registerResource(
"Custom Widget components",
"homarr://custom-widgets/components",
{ mimeType: "application/json" },
(uri) => ({
contents: [
{ uri: uri.href, mimeType: "application/json", text: JSON.stringify(getCustomWidgetComponentCatalog()) },
],
}),
);
server.registerResource(
"Custom Widget skill",
"homarr://custom-widgets/skill",
{ mimeType: "text/markdown" },
(uri) => ({
contents: [{ uri: uri.href, mimeType: "text/markdown", text: getCustomWidgetSkillContent() }],
}),
);
for (const example of customJsxExamples) {
server.registerResource(
`Custom Widget example: ${example.title}`,
`homarr://custom-widgets/examples/${example.id}`,
{ mimeType: "application/json" },
(uri) => ({
contents: [{ uri: uri.href, mimeType: "application/json", text: JSON.stringify(example) }],
}),
);
}
server.registerResource(
"Custom Widget component",
new ResourceTemplate("homarr://custom-widgets/components/{name}", { list: undefined }),
{
description: "Metadata for one installed Custom Widget component.",
mimeType: "application/json",
},
(uri, { name }) => {
const component = getCustomWidgetComponent(decodeURIComponent(String(name)));
if (!component) throw new Error("Component resource not found");
return {
contents: [{ uri: uri.href, mimeType: "application/json", text: JSON.stringify(component) }],
};
},
);
server.registerResource(
"Custom Widget example",
new ResourceTemplate("homarr://custom-widgets/examples/{name}", { list: undefined }),
{
description: "One canonical Custom Widget example.",
mimeType: "application/json",
},
(uri, { name }) => {
const example = getCustomWidgetExample(decodeURIComponent(String(name)));
if (!example) throw new Error("Example resource not found");
return {
contents: [{ uri: uri.href, mimeType: "application/json", text: JSON.stringify(example) }],
};
},
);
}
const SAFE_ERROR_MESSAGES: Record<string, string> = {
UNAUTHORIZED: "You do not have permission to perform this action",
FORBIDDEN: "Access denied",
NOT_FOUND: "The requested resource was not found",
BAD_REQUEST: "Invalid request parameters",
INTERNAL_SERVER_ERROR: "An internal error occurred",
TIMEOUT: "The operation timed out",
TOO_MANY_REQUESTS: "Rate limit exceeded, try again later",
CONFLICT: "A conflict occurred with the current state",
PRECONDITION_FAILED: "A precondition for this operation was not met",
};
function sanitizeErrorMessage(error: unknown): string {
if (error instanceof Error && "code" in error) {
const trpcCode = (error as Error & { code: string }).code;
const safeMessage = SAFE_ERROR_MESSAGES[trpcCode];
if (safeMessage) return safeMessage;
}
if (error instanceof Error && error.message.length < 200 && !error.message.includes("/")) {
return error.message;
}
return "Tool execution failed";
}
const SERVER_INSTRUCTIONS = `You are connected to Homarr, a self-hosted homelab dashboard.
## Key Concepts
**Integrations** are connections to external self-hosted services (Sonarr, Radarr, Plex, Overseerr, Pi-hole, Home Assistant, etc.). Each integration has an \`id\` (integrationId) and a \`kind\` (e.g. "sonarr", "radarr", "overseerr"). Use \`integration_all\` to list them. Many tools require an \`integrationId\` — always fetch it from \`integration_all\` or \`integration_search\` first.
**Boards** are customizable dashboards containing widgets that display data from integrations. Users can have multiple boards.
**Apps** are links/bookmarks to self-hosted services displayed on boards. They are separate from integrations — an app is a visual shortcut, an integration is a data connection.
**Permissions**: Each integration result includes a \`permissions\` object:
- \`hasUseAccess: true\` means you can read data from this integration
- \`hasInteractAccess: true\` means you can perform actions (start/stop, request media, etc.)
- \`hasFullAccess: true\` means you can modify/delete the integration config
If \`hasUseAccess\` is false, the API key owner lacks permission for that integration — this is normal, not an error. Simply skip integrations where you lack the required permission level.
## Common Workflows
**"What's coming this week?"** → Use \`calendar_findAllEvents\` to get upcoming media releases from Sonarr (TV), Radarr (movies), Lidarr (music), and Readarr (books).
**"Search and request a movie/show"** → Use \`integration_searchMediaRequests\` to search across Overseerr/Jellyseerr, then \`integration_requestMedia\` to submit a request. Use \`integration_getMediaRequestOptions\` first for TV shows to pick seasons.
**"What's streaming right now?"** → Use \`mediaServer_getCurrentStreams\` to see active Plex/Jellyfin/Emby streams.
**"Check server health"** → Use \`healthMonitoring_getSystemHealthStatus\` for NAS/server metrics or \`healthMonitoring_getClusterHealthStatus\` for Proxmox clusters.
**"Check Beszel systems"** → Call \`integration_all\` to get Beszel integrationIds, then use \`beszel_getSystems\` to list all monitored systems with CPU/memory/disk/network status. Use \`beszel_getAlerts\` for active alerts and history. Use \`beszel_getSystemStats\` for historical metrics of a specific system (requires systemId from \`beszel_getSystems\`).
**"Manage Docker containers"** → Use \`docker_getContainers\` to list containers with status/CPU/memory, then \`docker_startAll\`/\`docker_stopAll\`/\`docker_restartAll\` with container IDs.
**"Control smart home"** → Use \`smartHome_entityState\` to check a Home Assistant entity, \`smartHome_switchEntity\` to toggle it, or \`smartHome_executeAutomation\` to trigger an automation.
**"Block/unblock ads"** → Use \`dnsHole_summary\` for Pi-hole/AdGuard stats, \`dnsHole_disable\` to temporarily disable blocking, \`dnsHole_enable\` to re-enable.
**"Check downloads"** → Use \`downloads_getJobsAndStatuses\` for queue status, \`downloads_pause\`/\`downloads_resume\` to control queues.
**"Pending media requests"** → Use \`mediaRequests_getLatestRequests\` to see pending/approved/declined requests, \`mediaRequests_answerRequest\` to approve or decline.
## Tips
- Always call \`integration_all\` first to discover available integrations and their IDs before using integration-dependent tools.
- The \`integrationId\` parameter in widget tools accepts the ID from \`integration_all\`, not the integration name or kind.
- If a tool returns empty results, the user may not have the required integration configured.
- Docker and Kubernetes tools work only if the Homarr instance has access to the Docker socket or Kubernetes cluster.`;
function extractApiKeyValue(req: NextRequest): string | null {
const apiKeyHeader = req.headers.get(API_KEY_HEADER_NAME);
if (apiKeyHeader) return apiKeyHeader;
const authHeader = req.headers.get("Authorization");
if (authHeader?.startsWith("Bearer ")) return authHeader.slice(7);
return null;
}
function jsonErrorResponse(status: number, body: Record<string, string>, extraHeaders: Record<string, string> = {}) {
return new Response(JSON.stringify(body), {
status,
headers: { "Content-Type": "application/json", ...extraHeaders },
});
}
export const handleMcpRequest = async (req: NextRequest) => {
const pathname = new URL(req.url).pathname;
if (pathname.endsWith("/sse") || pathname.endsWith("/message")) {
return jsonErrorResponse(410, {
error: "transport_removed",
hint: "Legacy HTTP+SSE was removed. Connect to the stateless Streamable HTTP endpoint at /api/mcp.",
});
}
const apiKeyValue = extractApiKeyValue(req);
const ipAddress = ipAddressFromHeaders(req.headers);
const { ua } = userAgent(req);
if (!checkAuthRateLimit(ipAddress)) {
return jsonErrorResponse(
429,
{ error: "rate_limited", hint: "Too many failed authentication attempts. Try again later." },
{ "Retry-After": "60" },
);
}
if (!apiKeyValue) {
recordAuthFailure(ipAddress);
const baseUrl = extractBaseUrlFromHeaders(req.headers);
return jsonErrorResponse(
401,
{
error: "unauthorized",
hint: "Authenticate with an ApiKey header or via OAuth at /.well-known/oauth-authorization-server",
},
{ "WWW-Authenticate": `Bearer resource_metadata="${baseUrl}/.well-known/oauth-protected-resource"` },
);
}
if (!apiKeyValue.includes(".")) {
recordAuthFailure(ipAddress);
return jsonErrorResponse(401, { error: "invalid_token", hint: "The token must be in the format '<id>.<token>'." });
}
const session = await getSessionFromApiKeyAsync(db, apiKeyValue, ipAddress, ua);
if (!session) {
recordAuthFailure(ipAddress);
return jsonErrorResponse(401, {
error: "invalid_token",
hint: "The API key was not found or the token is incorrect.",
});
}
clearAuthFailures(ipAddress);
logger.info("MCP request authenticated", { userId: session.user.id });
const ctx = createTRPCContext({ session, headers: req.headers });
const caller = mcpRouter.createCaller(ctx);
const isAdmin = session.user.permissions.includes("admin");
const protocolHandler = createMcpProtocolHandler({
caller,
tools: getVisibleTools(isAdmin),
version: getPackageVersion(),
instructions: SERVER_INSTRUCTIONS,
configureServer: isAdmin ? registerCustomWidgetAuthoring : undefined,
formatToolError: sanitizeErrorMessage,
onToolError: (tool, error) => {
logger.warn("MCP tool execution failed", {
tool,
errorName: error instanceof Error ? error.name : "UnknownError",
errorCode: error instanceof Error && "code" in error && typeof error.code === "string" ? error.code : undefined,
});
},
onProtocolError: (error) => {
logger.warn("MCP protocol request failed", { error: error.message });
},
});
return protocolHandler.fetch(req, {
authInfo: {
token: apiKeyValue,
clientId: session.user.id,
scopes: ["mcp:tools"],
},
});
};
@@ -0,0 +1,140 @@
// @vitest-environment node
import { describe, expect, test, vi } from "vitest";
import { z } from "zod/v4";
import type { McpTool } from "@homarr/api/mcp";
import { createMcpProtocolHandler } from "./_protocol";
const modernMetadata = {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientInfo": { name: "homarr-test", version: "1.0.0" },
"io.modelcontextprotocol/clientCapabilities": {},
};
const createRequest = (method: string, params: Record<string, unknown> = {}, name = "homarr") =>
new Request("http://homarr.test/api/mcp", {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json, text/event-stream",
"Mcp-Method": method,
"Mcp-Name": name,
},
body: JSON.stringify({
jsonrpc: "2.0",
id: 1,
method,
params: { ...params, _meta: modernMetadata },
}),
});
const parseResponse = async (response: Response) => {
expect(response.status).toBe(200);
const body = await response.text();
const json = response.headers.get("Content-Type")?.includes("text/event-stream")
? body
.split("\n")
.find((line) => line.startsWith("data: "))
?.slice(6)
: body;
expect(json).toBeTruthy();
return JSON.parse(json ?? "") as {
result: Record<string, unknown>;
};
};
const echoValidator = z.object({ message: z.string() });
const echoTool: McpTool = {
name: "echo",
description: "Echo a message",
pathInRouter: ["echo"],
inputValidator: echoValidator,
inputSchema: z.toJSONSchema(echoValidator),
};
const createTestHandler = () => {
const echo = vi.fn(async (input: unknown) => input);
const handler = createMcpProtocolHandler({
caller: { echo },
tools: [echoTool],
version: "1.0.0",
instructions: "Test server",
formatToolError: () => "Tool failed",
});
return { echo, handler };
};
describe("MCP v2 protocol", () => {
test("discovers the stateless 2026-07-28 server", async () => {
const { handler } = createTestHandler();
const response = await parseResponse(await handler.fetch(createRequest("server/discover")));
expect(response.result.supportedVersions).toEqual(["2026-07-28"]);
expect(response.result).toMatchObject({ resultType: "complete", cacheScope: "private" });
});
test("returns a deterministic, privately cacheable tool catalog", async () => {
const { handler } = createTestHandler();
const response = await parseResponse(await handler.fetch(createRequest("tools/list")));
expect(response.result).toMatchObject({
resultType: "complete",
ttlMs: 300_000,
cacheScope: "private",
tools: [{ name: "echo", description: "Echo a message" }],
});
});
test("validates and calls Homarr procedures without a protocol session", async () => {
const { echo, handler } = createTestHandler();
const response = await parseResponse(
await handler.fetch(createRequest("tools/call", { name: "echo", arguments: { message: "hello" } }, "echo")),
);
expect(echo).toHaveBeenCalledWith({ message: "hello" });
expect(response.result.content).toEqual([{ type: "text", text: '{"message":"hello"}' }]);
});
test("keeps stateless compatibility with 2025 Streamable HTTP clients", async () => {
const { handler } = createTestHandler();
const request = new Request("http://homarr.test/api/mcp", {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json, text/event-stream",
},
body: JSON.stringify({
jsonrpc: "2.0",
id: 1,
method: "initialize",
params: {
protocolVersion: "2025-03-26",
capabilities: {},
clientInfo: { name: "legacy-test", version: "1.0.0" },
},
}),
});
const initializeResponse = await handler.fetch(request);
expect(initializeResponse.headers.has("Mcp-Session-Id")).toBe(false);
const response = await parseResponse(initializeResponse);
expect(response.result.protocolVersion).toBe("2025-03-26");
const listResponse = await handler.fetch(
new Request("http://homarr.test/api/mcp", {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json, text/event-stream",
},
body: JSON.stringify({ jsonrpc: "2.0", id: 2, method: "tools/list" }),
}),
);
expect(listResponse.headers.has("Mcp-Session-Id")).toBe(false);
expect((await parseResponse(listResponse)).result.tools).toEqual([expect.objectContaining({ name: "echo" })]);
});
});
+89
View File
@@ -0,0 +1,89 @@
import { createMcpHandler, McpServer } from "@modelcontextprotocol/server";
import type { McpTool } from "@homarr/api/mcp";
interface CreateMcpProtocolHandlerOptions {
caller: unknown;
tools: McpTool[];
version: string;
instructions: string;
configureServer?: (server: McpServer) => void;
formatToolError: (error: unknown) => string;
onToolError?: (toolName: string, error: unknown) => void;
onProtocolError?: (error: Error) => void;
}
const resolveProcedure = (caller: unknown, path: string[]) =>
path.reduce<unknown>(
(current, segment) =>
(typeof current === "object" || typeof current === "function") && current !== null
? (current as Record<string, unknown>)[segment]
: undefined,
caller,
);
export const createMcpProtocolHandler = ({
caller,
tools,
version,
instructions,
configureServer,
formatToolError,
onToolError,
onProtocolError,
}: CreateMcpProtocolHandlerOptions) =>
createMcpHandler(
() => {
const server = new McpServer(
{ name: "homarr", version },
{
capabilities: { tools: {} },
instructions,
cacheHints: {
"tools/list": { ttlMs: 300_000, cacheScope: "private" },
},
},
);
for (const tool of tools) {
server.registerTool(
tool.name,
{
description: tool.description,
inputSchema: tool.inputValidator,
},
async (args) => {
try {
const procedure = resolveProcedure(caller, tool.pathInRouter);
if (typeof procedure !== "function") {
return {
content: [{ type: "text" as const, text: `Tool "${tool.name}" is not callable` }],
isError: true,
};
}
const input = Object.keys(args).length > 0 ? args : undefined;
const result = await (procedure as (value: unknown) => Promise<unknown>)(input);
return {
content: [{ type: "text" as const, text: JSON.stringify(result) }],
};
} catch (error) {
onToolError?.(tool.name, error);
return {
content: [{ type: "text" as const, text: JSON.stringify({ error: formatToolError(error) }) }],
isError: true,
};
}
},
);
}
configureServer?.(server);
return server;
},
{
legacy: "stateless",
onerror: onProtocolError,
},
);
@@ -15,6 +15,7 @@ interface AuthCode {
codeChallenge: string;
codeChallengeMethod: string;
redirectUri: string;
resource: string | null;
expiresAt: number;
}
@@ -24,6 +25,7 @@ interface PendingAuth {
codeChallenge: string;
codeChallengeMethod: string;
state: string | null;
resource: string | null;
expiresAt: number;
}
@@ -102,6 +104,7 @@ export function createAuthCode(
codeChallenge: string,
codeChallengeMethod: string,
redirectUri: string,
resource: string | null,
) {
if (authCodes.size >= MAX_AUTH_CODES) {
return null;
@@ -115,6 +118,7 @@ export function createAuthCode(
codeChallenge,
codeChallengeMethod,
redirectUri,
resource,
expiresAt: Date.now() + AUTH_CODE_TTL_MS,
});
return code;
@@ -1,6 +1,7 @@
import type { NextRequest } from "next/server";
import { auth } from "@homarr/auth/next";
import { extractBaseUrlFromHeaders } from "@homarr/common";
import { consumePendingAuth, createAuthCode, getClient, storePendingAuth } from "../_store";
@@ -37,6 +38,7 @@ export async function GET(req: NextRequest) {
pending.codeChallenge,
pending.codeChallengeMethod,
pending.redirectUri,
pending.resource,
);
if (!code) {
return Response.json(
@@ -59,6 +61,7 @@ export async function GET(req: NextRequest) {
const codeChallenge = url.searchParams.get("code_challenge");
const codeChallengeMethod = url.searchParams.get("code_challenge_method") ?? "S256";
const state = url.searchParams.get("state");
const resource = url.searchParams.get("resource");
if (!clientId || !redirectUri || !codeChallenge) {
return Response.json(
@@ -80,6 +83,17 @@ export async function GET(req: NextRequest) {
);
}
const expectedResource = `${extractBaseUrlFromHeaders(req.headers)}/api/mcp`;
if (resource && resource !== expectedResource) {
return Response.json(
{
error: "invalid_target",
error_description: "The resource must be the canonical Homarr MCP endpoint",
},
{ status: 400 },
);
}
const client = getClient(clientId);
if (!client) {
return Response.json(
@@ -103,7 +117,7 @@ export async function GET(req: NextRequest) {
}
if (session?.user) {
const code = createAuthCode(clientId, session.user.id, codeChallenge, codeChallengeMethod, redirectUri);
const code = createAuthCode(clientId, session.user.id, codeChallenge, codeChallengeMethod, redirectUri, resource);
if (!code) {
return Response.json(
{
@@ -126,6 +140,7 @@ export async function GET(req: NextRequest) {
codeChallenge,
codeChallengeMethod,
state,
resource,
});
if (!id) {
return Response.json(
@@ -80,7 +80,7 @@ export async function POST(req: Request) {
);
}
const { grant_type, code, code_verifier, redirect_uri } = params;
const { grant_type, code, code_verifier, redirect_uri, resource } = params;
if (grant_type !== "authorization_code") {
return Response.json({ error: "unsupported_grant_type" }, { status: 400 });
@@ -111,6 +111,11 @@ export async function POST(req: Request) {
return Response.json({ error: "invalid_grant", error_description: "redirect_uri mismatch" }, { status: 400 });
}
if (authCode.resource && resource !== authCode.resource) {
recordTokenFailure(ip);
return Response.json({ error: "invalid_target", error_description: "resource mismatch" }, { status: 400 });
}
if (!verifyPkce(code_verifier, authCode.codeChallenge, authCode.codeChallengeMethod)) {
recordTokenFailure(ip);
return Response.json({ error: "invalid_grant", error_description: "PKCE verification failed" }, { status: 400 });
+1
View File
@@ -0,0 +1 @@
export { handleMcpRequest as POST } from "./_handler";
+83
View File
@@ -0,0 +1,83 @@
import type { AnyRootTypes, Router, RouterRecord } from "@trpc/server/unstable-core-do-not-import";
import { z } from "zod/v4";
export interface McpMeta {
mcp?: {
enabled: boolean;
name?: string;
description?: string;
};
}
export interface McpTool {
name: string;
description: string;
pathInRouter: string[];
inputSchema: z.core.JSONSchema.JSONSchema;
inputValidator: z.ZodObject;
}
const emptyInput = z.object({});
const mergeInputs = (inputs: z.ZodObject[]) => inputs.reduce((merged, input) => merged.extend(input.shape), emptyInput);
const normalizeJsonSchema = (schema: z.core.JSONSchema.JSONSchema): z.core.JSONSchema.JSONSchema => {
const properties = (schema.properties ?? {}) as Record<string, Record<string, unknown>>;
const required = ((schema.required as string[] | undefined) ?? []).filter(
(key) => !("default" in (properties[key] ?? {})),
);
return {
...schema,
type: "object",
properties,
required,
};
};
export function extractMcpToolsFromProcedures<TRoot extends AnyRootTypes, TRecord extends RouterRecord>(
router: Router<TRoot, TRecord>,
): McpTool[] {
const tools: McpTool[] = [];
for (const [path, procedure] of Object.entries(router["_def"].procedures)) {
const definition = procedure["_def"] as unknown as {
inputs?: z.ZodType[];
meta?: McpMeta;
};
const metadata = definition.meta?.mcp;
if (!metadata?.enabled) continue;
const inputs = (definition.inputs ?? []).filter((input): input is z.ZodType => input !== undefined);
const procedureInput =
inputs.length === 0
? emptyInput
: inputs.length === 1
? (inputs[0] ?? emptyInput)
: inputs.every((input): input is z.ZodObject => input instanceof z.ZodObject)
? mergeInputs(inputs)
: null;
if (
procedureInput === null ||
(!(procedureInput instanceof z.ZodObject) && !procedureInput.safeParse(undefined).success)
) {
console.warn(`[MCP] Procedure ${path} does not use an object input schema; using an empty schema`);
}
const inputValidator = procedureInput instanceof z.ZodObject ? procedureInput : emptyInput;
tools.push({
name: metadata.name ?? path.replaceAll(".", "_"),
description: metadata.description ?? "",
pathInRouter: path.split("."),
inputValidator,
inputSchema: normalizeJsonSchema(
z.toJSONSchema(inputValidator, {
unrepresentable: "any",
}),
),
});
}
return tools.toSorted((left, right) => left.name.localeCompare(right.name));
}
+2
View File
@@ -1,4 +1,6 @@
export { createTRPCContext } from "./trpc";
export { extractMcpToolsFromProcedures } from "./mcp-tools";
export type { McpTool } from "./mcp-tools";
import { createTRPCRouter } from "./trpc";
import { appRouter as appRouterForApps } from "./router/app";
+1 -1
View File
@@ -8,7 +8,6 @@
*/
import { initTRPC, TRPCError } from "@trpc/server";
import superjson from "superjson";
import type { McpMeta } from "trpc-to-mcp";
import type { OpenApiMeta } from "trpc-to-openapi";
import { ZodError } from "zod/v4";
@@ -22,6 +21,7 @@ import type { GroupPermissionKey, OnboardingStep } from "@homarr/definitions";
import { env } from "./env";
import { getOnboardingOrFallbackAsync } from "./router/onboard/onboard-queries";
import type { McpMeta } from "./mcp-tools";
const logger = createLogger({ module: "trpc" });
+1 -3
View File
@@ -34,7 +34,7 @@ catalog:
"@immich/sdk": ^3.0.2
"@jellyfin/sdk": ^0.13.0
"@kubernetes/client-node": ^1.4.0
"@modelcontextprotocol/sdk": 1.29.0
"@modelcontextprotocol/server": 2.0.0
"@mantine/charts": ^9.4.1
"@mantine/colors-generator": ^9.4.1
"@mantine/core": ^9.4.1
@@ -165,7 +165,6 @@ catalog:
mantine-datatable: ^9.3.1
mantine-react-table: npm:mantine-react-table-open@9.0.3
mermaid: ^11.15.0
mcp-handler: ^1.1.0
maria2: ^0.4.1
mysql2: 3.22.6
next: 16.3.0-preview.9
@@ -197,7 +196,6 @@ catalog:
svgson: ^5.3.1
testcontainers: ^12.0.4
timezones-ical-library: ^2.2.1
trpc-to-mcp: ^1.3.2
trpc-to-openapi: ^3.3.0
tsdav: ^2.3.0
tsx: 4.20.4