import type { NextRequest } from "next/server"; import { userAgent } from "next/server"; import type { McpServer } from "@modelcontextprotocol/server"; import { ResourceTemplate } 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 { getSafeAssistantToolError } from "../assistant/chat/assistant-tool-error"; 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; cleanupTimer: ReturnType | 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 = { 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, toolName: string): string { if (toolName.startsWith("customWidget_")) return getSafeAssistantToolError(error, { toolName }); 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, extraHeaders: Record = {}) { 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 '.'." }); } 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"], }, }); };