Merge widget option and advanced focus fixes into v2 roll-up

This commit is contained in:
Thomas Camlong committed 2026-08-09 09:21:15 +02:00
commit f08a365fc7
60 files changed
+930 -1280

No files matched your search

+24 -8
View File
@@ -8,7 +8,12 @@ tags:
# Model Context Protocol (MCP)
Homarr exposes a [Model Context Protocol](https://modelcontextprotocol.io) server at `/api/mcp/mcp`, enabling AI assistants like Claude, Cursor, VS Code Copilot, and Claude Code to interact with your Homarr instance using natural language.
Homarr exposes a [Model Context Protocol](https://modelcontextprotocol.io) server at `/api/mcp`, enabling AI assistants like Claude, Cursor, VS Code Copilot, and Claude Code to interact with your Homarr instance using natural language.
The endpoint implements the stateless MCP 2026-07-28 protocol and accepts 2025 Streamable HTTP clients through a
stateless compatibility path. It does not create protocol sessions or require sticky sessions. Homarr creates the
server and extracts its tools only after an authenticated request reaches the route. The previous `/api/mcp/mcp` URL
remains available as a compatibility alias, but new clients should always use `/api/mcp`.
With MCP, you can ask your AI assistant to manage apps, integrations, users, boards, Docker containers, media servers, DNS settings, and more - all through Homarr's existing tRPC API, now exposed as 50+ AI-callable tools.
@@ -46,7 +51,7 @@ Add the following to your client's MCP configuration file:
{
"mcpServers": {
"homarr": {
"url": "https://your-homarr-instance.com/api/mcp/mcp",
"url": "https://your-homarr-instance.com/api/mcp",
"headers": {
"ApiKey": "<your-api-key>"
}
@@ -77,7 +82,7 @@ Replace `<your-api-key>` with your actual API key. Never commit API keys to vers
"mcpServers": {
"homarr": {
"command": "npx",
"args": ["-y", "mcp-remote", "https://your-homarr-instance.com/api/mcp/mcp", "--header", "ApiKey:<your-api-key>"]
"args": ["-y", "mcp-remote", "https://your-homarr-instance.com/api/mcp", "--header", "ApiKey:<your-api-key>"]
}
}
}
@@ -100,6 +105,10 @@ Custom Widget and Workshop authoring are different: their tools, prompts, and re
key owner is a Homarr administrator. Non-administrator keys cannot discover or invoke those capabilities. Creating an
API key never elevates the owner.
OAuth authorization and token requests bind issued credentials to the canonical `/api/mcp` resource. Dynamic client
registration remains available for compatibility with current MCP clients while the ecosystem transitions toward
pre-registered clients and Client ID Metadata Documents.
## Testing the Connection
Verify your MCP endpoint is working:
@@ -107,13 +116,17 @@ Verify your MCP endpoint is working:
```bash
curl -s -X POST \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-H "Accept: application/json" \
-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}' \
https://your-homarr-instance.com/api/mcp/mcp
-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}' \
https://your-homarr-instance.com/api/mcp
```
A successful response includes the server capabilities and available tools. Use `tools/list` to see all available tools:
A successful response includes the supported protocol versions and server capabilities. Use `tools/list` to see all
available tools. MCP v2 clients add the per-request metadata and `Mcp-Method` / `Mcp-Name` headers automatically; the
abbreviated request below uses the stateless 2025 compatibility path for readability:
```bash
curl -s -X POST \
@@ -121,9 +134,12 @@ curl -s -X POST \
-H "Accept: application/json, text/event-stream" \
-H "ApiKey: <your-api-key>" \
-d '{"jsonrpc":"2.0","method":"tools/list","id":1}' \
https://your-homarr-instance.com/api/mcp/mcp
https://your-homarr-instance.com/api/mcp
```
Tool catalogs are returned in deterministic order and may be cached privately by the client for five minutes. The
removed HTTP+SSE `/sse` and `/message` transports return `410 Gone`; use the single Streamable HTTP endpoint instead.
## Available Tools
Homarr exposes 50+ tools organized by namespace. Each tool is either a **query** (GET, read-only) or **mutation** (POST, write action).
+1 -1
View File
@@ -18,7 +18,7 @@ Temporary inputs exist only while the tile is mounted. Homarr encrypts source cr
Homarr does not send these credentials to the browser. Homarr does not include them in an export.
<WidgetInteractionGuide />
<WidgetInteractionGuide advancedView={false} />
### Adding the widget
+1 -3
View File
@@ -67,7 +67,7 @@
"@mantine/hooks": "catalog:",
"@mantine/modals": "catalog:",
"@mantine/tiptap": "catalog:",
"@modelcontextprotocol/sdk": "catalog:",
"@modelcontextprotocol/server": "catalog:",
"@scalar/api-reference-react": "catalog:",
"@tabler/icons-react": "catalog:",
"@tanstack/react-devtools": "catalog:",
@@ -99,7 +99,6 @@
"lucide-react": "catalog:",
"mantine-datatable": "catalog:",
"mantine-react-table": "catalog:",
"mcp-handler": "catalog:",
"mermaid": "catalog:",
"next": "catalog:",
"postcss-preset-mantine": "catalog:",
@@ -115,7 +114,6 @@
"sass": "catalog:",
"sql.js": "catalog:",
"superjson": "catalog:",
"trpc-to-mcp": "catalog:",
"use-deep-compare-effect": "catalog:",
"yaml": "catalog:",
"zod": "catalog:"
@@ -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";
-1
View File
@@ -61,7 +61,6 @@
"react": "catalog:",
"react-dom": "catalog:",
"superjson": "catalog:",
"trpc-to-mcp": "catalog:",
"trpc-to-openapi": "catalog:",
"undici": "catalog:",
"zod": "catalog:"
+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";
@@ -84,7 +84,11 @@ const createSession = (userId: string): Session => ({
expires: new Date(Date.now() + 60_000).toISOString(),
});
async function setup(isPublic = true) {
async function setup(
isPublic = true,
customDefinition: HomarrCustomWidgetV2 = definition,
configuration: Record<string, unknown> = {},
) {
const db = createDb();
const ownerId = createId();
const boardId = createId();
@@ -94,14 +98,14 @@ async function setup(isPublic = true) {
await db.insert(boards).values({ id: boardId, name: createId(), creatorId: ownerId, isPublic });
await db.insert(customWidgetDefinitions).values({
id: definitionId,
...serializeCustomWidgetDefinition(definition),
...serializeCustomWidgetDefinition(customDefinition),
creatorId: ownerId,
});
await db.insert(items).values({
id: itemId,
boardId,
kind: "customApi",
options: stringifySuperJson({ definitionId, configuration: {}, configurationVersion: 1 }),
options: stringifySuperJson({ definitionId, configuration, configurationVersion: 1 }),
});
return { db, ownerId, boardId, itemId, definitionId };
}
@@ -122,6 +126,20 @@ describe("Custom JSX v2 board router", () => {
expect(mocks.executeRequest).toHaveBeenCalledOnce();
});
test("uses current defaults when an updated option no longer accepts the stored value", async () => {
const updatedDefinition: HomarrCustomWidgetV2 = {
...definition,
options: {
limit: { label: "Limit", control: "slider", default: 5, min: 1, max: 10 },
},
template: "<Text>{options.limit}</Text>",
};
const { db, itemId } = await setup(true, updatedDefinition, { limit: "5", removed: true });
const caller = customApiRouter.createCaller({ db, deviceType: undefined, session: null });
await expect(caller.getData({ itemId })).resolves.toMatchObject({ options: { limit: 5 } });
});
test("hides private boards and disabled definitions before executing a request", async () => {
const privateSetup = await setup(false);
const anonymous = customApiRouter.createCaller({
@@ -9,6 +9,7 @@ import type { BoardPermission } from "@homarr/definitions";
import {
getCustomWidgetConfirmation,
getCustomWidgetDefaultOptions,
normalizeCustomWidgetOptions,
validateCustomWidgetOptions,
} from "@homarr/custom-widgets/core";
import type { CustomJsxRequest, CustomWidgetSource } from "@homarr/custom-widgets/core";
@@ -96,7 +97,10 @@ async function resolvePlacedDefinitionAsync(ctx: RouterContext, itemId: string)
if (!stored.enabled) throw new TRPCError({ code: "FORBIDDEN", message: "Widget is disabled" });
const definition = parseStoredCustomWidgetDefinition(stored);
const configuration = { ...getCustomWidgetDefaultOptions(definition.options), ...itemOptions.configuration };
const configuration =
itemOptions.configurationVersion === stored.updatedAt.getTime()
? { ...getCustomWidgetDefaultOptions(definition.options), ...itemOptions.configuration }
: normalizeCustomWidgetOptions(definition.options, itemOptions.configuration);
const issues = validateCustomWidgetOptions(definition.options, configuration);
if (issues.length > 0) {
throw new TRPCError({
+13 -4
View File
@@ -1,8 +1,9 @@
import type { TRPCError } from "@trpc/server";
import { describe, expect, test, vi } from "vitest";
import { extractToolsFromProcedures } from "trpc-to-mcp";
import { z } from "zod/v4";
import { mcpRouter } from "../mcp";
import { extractMcpToolsFromProcedures } from "../mcp-tools";
vi.mock("@homarr/auth", () => ({}));
@@ -125,7 +126,7 @@ function actualToolInventory() {
string,
{ _def?: { type?: "query" | "mutation" } }
>;
return extractToolsFromProcedures(mcpRouter)
return extractMcpToolsFromProcedures(mcpRouter)
.map((tool) => {
const procedure = procedures[tool.pathInRouter.join(".")];
const type = procedure?.["_def"]?.type;
@@ -144,7 +145,7 @@ describe("production MCP router", () => {
});
test("keeps every secret-bearing tool behind a mutation procedure", () => {
const tools = extractToolsFromProcedures(mcpRouter);
const tools = extractMcpToolsFromProcedures(mcpRouter);
const procedures = mcpRouter["_def"].procedures as unknown as Record<
string,
{ _def?: { type?: "query" | "mutation" } }
@@ -158,7 +159,7 @@ describe("production MCP router", () => {
});
test("gives every production tool a description", () => {
const tools = extractToolsFromProcedures(mcpRouter);
const tools = extractMcpToolsFromProcedures(mcpRouter);
for (const tool of tools) expect(tool.description, `Tool ${tool.name} should have a description`).toBeTruthy();
expect(tools.find((tool) => tool.name === "customWidget_getAuthoringPrompt")?.description).toBe(
"Get the current Custom Widget authoring instructions.",
@@ -166,6 +167,14 @@ describe("production MCP router", () => {
});
});
test("MCP tools are deterministically ordered and retain executable schemas", () => {
const tools = extractMcpToolsFromProcedures(mcpRouter);
const toolNames = tools.map((tool) => tool.name);
expect(toolNames).toEqual(toolNames.toSorted((left, right) => left.localeCompare(right)));
expect(tools.every((tool) => tool.inputValidator instanceof z.ZodObject)).toBe(true);
});
describe("custom widget authoring procedure access", () => {
const unauthenticatedCaller = mcpRouter.createCaller({
db: null as never,
+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" });
@@ -12,6 +12,18 @@ export function getCustomWidgetDefaultOptions(
return Object.fromEntries(Object.entries(options).map(([name, option]) => [name, option.default]));
}
export function normalizeCustomWidgetOptions(
options: CustomWidgetOptions,
value: Record<string, unknown>,
): Record<string, unknown> {
return Object.fromEntries(
Object.entries(options).map(([name, option]) => [
name,
getCustomWidgetOptionValueIssue(option, value[name]) ? option.default : value[name],
]),
);
}
export function validateCustomWidgetOptions(
options: CustomWidgetOptions,
value: Record<string, unknown>,
@@ -5,6 +5,7 @@ import {
customWidgetOptionControls,
customWidgetOptionSchema,
getCustomWidgetDefaultOptions,
normalizeCustomWidgetOptions,
validateCustomWidgetOptions,
} from "../core";
@@ -69,6 +70,29 @@ describe("lean Custom Widget options", () => {
expect(validateCustomWidgetOptions(options, configuration)).toEqual([]);
});
it("replaces stale values with current defaults", () => {
const options = customWidgetDefinitionSchema.parse({
$schema: "homarr-custom-widget-v2",
name: "Updated options",
sources: {},
requests: {},
options: {
limit: { label: "Limit", control: "slider", default: 5, min: 1, max: 10 },
label: { label: "Label", control: "text", default: "Default" },
},
template: "<Text>{options.limit}</Text>",
}).options;
const normalized = normalizeCustomWidgetOptions(options, {
limit: "5",
label: "Custom",
removed: true,
});
expect(normalized).toEqual({ limit: 5, label: "Custom" });
expect(validateCustomWidgetOptions(options, normalized)).toEqual([]);
});
it("rejects duplicate static choice values", () => {
expect(
customWidgetOptionSchema.safeParse({
@@ -21,7 +21,7 @@ import { IconAlertTriangle } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { useOptionalBoard } from "@homarr/boards/context";
import type { CustomWidgetOption } from "@homarr/custom-widgets/core";
import { validateCustomWidgetOptions } from "@homarr/custom-widgets/core";
import { normalizeCustomWidgetOptions, validateCustomWidgetOptions } from "@homarr/custom-widgets/core";
import { CustomWidgetCodeEditor } from "@homarr/custom-widgets/workbench";
import type { CustomWidgetEditorMessages } from "@homarr/custom-widgets/workbench";
import { IconPicker } from "@homarr/forms-collection";
@@ -45,10 +45,23 @@ export const WidgetCustomWidgetConfigurationInput = ({
);
const definition = available.data?.find((candidate) => candidate.id === definitionId);
const options = isRecord(definition?.options) ? (definition.options as Record<string, CustomWidgetOption>) : null;
const issues = useMemo(
() => (options ? validateCustomWidgetOptions(options, configuration) : []),
[configuration, options],
const definitionVersion = definition?.updatedAt instanceof Date ? definition.updatedAt.getTime() : undefined;
const configurationVersion = form.values.options.configurationVersion;
const needsDefinitionRepair = definitionVersion !== undefined && configurationVersion !== definitionVersion;
const effectiveConfiguration = useMemo(
() => (options && needsDefinitionRepair ? normalizeCustomWidgetOptions(options, configuration) : configuration),
[configuration, needsDefinitionRepair, options],
);
const issues = useMemo(
() => (options ? validateCustomWidgetOptions(options, effectiveConfiguration) : []),
[effectiveConfiguration, options],
);
useEffect(() => {
if (!needsDefinitionRepair || definitionVersion === undefined) return;
form.setFieldValue(`options.${property}`, effectiveConfiguration);
form.setFieldValue("options.configurationVersion", definitionVersion);
}, [definitionVersion, effectiveConfiguration, form, needsDefinitionRepair, property]);
useEffect(() => {
const path = `options.${property}`;
@@ -72,7 +85,7 @@ export const WidgetCustomWidgetConfigurationInput = ({
key={`${definitionId}:${name}`}
option={option}
path={`options.${property}.${name}`}
configuration={configuration}
configuration={effectiveConfiguration}
definitionId={definitionId}
/>
))}
@@ -87,7 +100,7 @@ export const WidgetCustomWidgetConfigurationInput = ({
key={`${definitionId}:${name}`}
option={option}
path={`options.${property}.${name}`}
configuration={configuration}
configuration={effectiveConfiguration}
definitionId={definitionId}
/>
))}
@@ -3,41 +3,44 @@ import { describe, expect, test } from "vitest";
import { supportsAdvancedFocus } from "./definition";
import { widgetImports } from ".";
const compactOnlyWidgetKinds = [
"app",
"bazarr",
"beszelAlerts",
"beszelSystemGrid",
"beszelSystemStats",
"beszelSystemTable",
"coolify",
"dnsHoleControls",
"dnsHoleSummary",
"iframe",
"indexerManager",
"mediaRequests-requestList",
"mediaRequests-requestStats",
"minecraftServerStatus",
"networkControllerSummary",
"notebook",
"notifications",
"paperlessNgx",
"patchmon",
"releases",
"rssFeed",
"stockPrice",
"systemDisks",
"video",
"vpn",
const advancedFocusWidgetKinds = [
"anchorNote",
"archiveTeamWarrior",
"audioStats",
"bookmarks",
"calendar",
"clock",
"dockerContainers",
"downloads",
"firewall",
"healthMonitoring",
"immich-albumCarousel",
"immich-serverStats",
"mediaMissing",
"mediaReleases",
"mediaServer",
"mediaTranscoding",
"networkControllerStatus",
"smartHome-entityState",
"smartHome-executeAutomation",
"speedtestTracker",
"systemResources",
"timetable",
"tracearr",
"traefik",
"umami",
"ups",
"uptimeKuma",
"weather",
] as const;
describe("advanced focus support", () => {
test("is reserved for widgets with a distinct advanced experience", () => {
const actualCompactOnlyKinds = Object.entries(widgetImports)
.filter(([, widgetImport]) => !supportsAdvancedFocus(widgetImport.definition))
const actualAdvancedFocusKinds = Object.entries(widgetImports)
.filter(([, widgetImport]) => supportsAdvancedFocus(widgetImport.definition))
.map(([kind]) => kind)
.toSorted();
expect(actualCompactOnlyKinds).toEqual([...compactOnlyWidgetKinds].toSorted());
expect(actualAdvancedFocusKinds).toEqual([...advancedFocusWidgetKinds].toSorted());
});
});
@@ -5,6 +5,7 @@ import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("anchorNote", {
icon: IconNotes,
supportsAdvancedFocus: true,
queryKey: [["widget", "anchorNotes"]],
maxIntegrations: 1,
supportedIntegrations: ["anchor"],
@@ -5,6 +5,7 @@ import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("archiveTeamWarrior", {
icon: IconArchive,
supportsAdvancedFocus: true,
maxIntegrations: 1,
createOptions() {
return optionsBuilder.from((factory) => ({
@@ -1,8 +1,13 @@
import { describe, expect, it } from "vitest";
import { supportsAdvancedFocus } from "../definition";
import { definition } from ".";
describe("assistant widget definition", () => {
it("stays compact without an explicit advanced-focus opt-in", () => {
expect(supportsAdvancedFocus(definition)).toBe(false);
});
it("follows the current conversation by default", () => {
const options = definition.createOptions();
@@ -13,6 +13,7 @@ const hideUnlessAudiobookshelf = {
export const { definition, componentLoader } = createWidgetDefinition("audioStats", {
icon: IconHeadphones,
supportsAdvancedFocus: true,
queryKeys: [[["widget", "audioStats", "getStats"]], [["widget", "mediaServer", "getCurrentStreams"]]],
queryMatcher: matchesWidgetRuntimeQuery,
supportedIntegrations: ["navidrome", "audiobookshelf"],
+1
View File
@@ -10,6 +10,7 @@ import { BookmarkAddButton } from "./add-button";
export const { definition, componentLoader } = createWidgetDefinition("bookmarks", {
icon: IconBookmark,
supportsAdvancedFocus: true,
queryKey: [["app", "byIds"]],
createOptions() {
return optionsBuilder.from((factory) => ({
+1
View File
@@ -9,6 +9,7 @@ import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("calendar", {
icon: IconCalendar,
supportsAdvancedFocus: true,
queryKey: [["widget", "calendar", "findAllEvents"]],
queryMatcher: matchesWidgetRuntimeQuery,
createOptions() {
+1
View File
@@ -6,6 +6,7 @@ import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("clock", {
icon: IconClock,
supportsAdvancedFocus: true,
queryKey: [["widget", "weather", "atLocation"]],
queryMatcher: ({ input }, scope) => {
const location = scope.options.weatherLocation;
+8 -1
View File
@@ -2,7 +2,9 @@ import { describe, expect, test } from "vitest";
import { definition as appDefinition } from "./app";
import { definition as beszelSystemGridDefinition } from "./beszel-system-grid";
import { definition as beszelSystemTableDefinition } from "./beszel-system-table";
import { definition as customApiDefinition } from "./custom-api";
import { getWidgetQueryKeys, supportsAdvancedFocus } from "./definition";
import { definition as downloadsDefinition } from "./downloads";
describe("getWidgetQueryKeys", () => {
test("falls back to the widget router namespace", () => {
@@ -24,10 +26,15 @@ describe("getWidgetQueryKeys", () => {
});
describe("supportsAdvancedFocus", () => {
test.each([appDefinition, beszelSystemGridDefinition, beszelSystemTableDefinition])(
test.each([appDefinition, beszelSystemGridDefinition, beszelSystemTableDefinition, customApiDefinition])(
"keeps non-enhanced widgets compact",
(widgetDefinition) => {
expect(supportsAdvancedFocus(widgetDefinition)).toBe(false);
},
);
test("requires an explicit opt-in", () => {
expect(supportsAdvancedFocus({})).toBe(false);
expect(supportsAdvancedFocus(downloadsDefinition)).toBe(true);
});
});
+1 -1
View File
@@ -127,7 +127,7 @@ export interface WidgetDefinition {
}
export const supportsAdvancedFocus = (definition: object) =>
!("supportsAdvancedFocus" in definition) || definition.supportsAdvancedFocus !== false;
"supportsAdvancedFocus" in definition && definition.supportsAdvancedFocus === true;
export const getWidgetQueryKeys = (
definition: {
+1
View File
@@ -25,6 +25,7 @@ const columnTranslationKeyMap = {
export const { definition, componentLoader } = createWidgetDefinition("dockerContainers", {
icon: IconBrandDocker,
supportsAdvancedFocus: true,
queryKey: [["docker", "getContainers"]],
refetchInterval: 30,
createOptions() {
+1
View File
@@ -42,6 +42,7 @@ const sortColumns = [
export const { definition, componentLoader } = createWidgetDefinition("downloads", {
icon: IconDownload,
supportsAdvancedFocus: true,
queryKey: [["widget", "downloads", "getJobsAndStatuses"]],
queryMatcher: ({ input }, scope) =>
widgetQueryInputMatches(input, {
+1
View File
@@ -7,6 +7,7 @@ import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("firewall", {
icon: IconWall,
supportsAdvancedFocus: true,
refetchInterval: 5,
createOptions() {
return optionsBuilder.from(() => ({}));
@@ -8,6 +8,7 @@ import { createStorageVolumeMultiSelectOptions } from "../storage-volume-options
export const { definition, componentLoader } = createWidgetDefinition("healthMonitoring", {
icon: IconHeartRateMonitor,
supportsAdvancedFocus: true,
queryKeys: [[["integration", "byIds"]], [["widget", "healthMonitoring"]]],
refetchInterval: 5,
createOptions() {
@@ -51,6 +51,7 @@ const createOptions = () =>
export const { definition, componentLoader } = createWidgetDefinition("immich-albumCarousel", {
icon: IconPhoto,
supportsAdvancedFocus: true,
queryKey: [["widget", "immich", "getAlbum"]],
queryMatcher: ({ input }, scope) =>
widgetQueryInputMatches(input, {
@@ -5,6 +5,7 @@ import { optionsBuilder } from "../../options";
export const { definition, componentLoader } = createWidgetDefinition("immich-serverStats", {
icon: IconGraphFilled,
supportsAdvancedFocus: true,
queryKeys: [[["widget", "immich", "getServerStats"]], [["widget", "immich", "getAlbums"]]],
queryMatcher: matchesWidgetRuntimeQuery,
refetchInterval: null,
@@ -9,6 +9,7 @@ const pageSizeOptions = ["10", "20", "30", "50"] as const;
export const { componentLoader, definition } = createWidgetDefinition("mediaMissing", {
icon: IconMovie,
supportsAdvancedFocus: true,
queryKey: [["widget", "mediaOrganizer", "getData"]],
queryMatcher: matchesWidgetRuntimeQuery,
createOptions() {
@@ -5,6 +5,7 @@ import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("mediaReleases", {
icon: IconTicket,
supportsAdvancedFocus: true,
queryKey: [["widget", "mediaRelease"]],
refetchInterval: null,
createOptions() {
@@ -7,6 +7,7 @@ import { optionsBuilder } from "../options";
export const { componentLoader, definition } = createWidgetDefinition("mediaServer", {
icon: IconVideo,
supportsAdvancedFocus: true,
queryKey: [["widget", "mediaServer", "getCurrentStreams"]],
queryMatcher: ({ input }, scope) =>
widgetQueryInputMatches(input, {
@@ -11,6 +11,7 @@ export const views = ["workers", "queue", "statistics"] as const;
export const { componentLoader, definition } = createWidgetDefinition("mediaTranscoding", {
icon: IconTransform,
supportsAdvancedFocus: true,
queryKey: [["widget", "mediaTranscoding", "getDataAsync"]],
queryMatcher: matchesWidgetRuntimeQuery,
maxIntegrations: 1,
@@ -7,6 +7,7 @@ import { optionsBuilder } from "../../options";
export const { definition, componentLoader } = createWidgetDefinition("networkControllerStatus", {
icon: IconTopologyFull,
supportsAdvancedFocus: true,
queryKey: [["widget", "networkController"]],
refetchInterval: null,
createOptions() {
@@ -7,6 +7,7 @@ import { optionsBuilder } from "../../options";
export const { definition, componentLoader } = createWidgetDefinition("smartHome-entityState", {
icon: IconBinaryTree,
supportsAdvancedFocus: true,
queryKey: [["widget", "smartHome"]],
maxIntegrations: 1,
createOptions() {
@@ -7,6 +7,7 @@ import { optionsBuilder } from "../../options";
export const { definition, componentLoader } = createWidgetDefinition("smartHome-executeAutomation", {
icon: IconBinaryTree,
supportsAdvancedFocus: true,
maxIntegrations: 1,
createOptions() {
return optionsBuilder.from((factory) => ({
@@ -5,6 +5,7 @@ import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("speedtestTracker", {
icon: IconSpeedboat,
supportsAdvancedFocus: true,
refetchInterval: null,
createOptions() {
return optionsBuilder.from(
@@ -14,6 +14,7 @@ const labelDisplayModeOptions = {
export const { definition, componentLoader } = createWidgetDefinition("systemResources", {
icon: IconGraphFilled,
supportsAdvancedFocus: true,
queryKey: [["widget", "healthMonitoring"]],
refetchInterval: 5,
supportedIntegrations: ["dashDot", "openmediavault", "truenas", "unraid", "glances", "synology"],
+1
View File
@@ -9,6 +9,7 @@ import { optionsBuilder } from "../options";
export const { componentLoader, definition } = createWidgetDefinition("timetable", {
icon: IconBusStop,
supportsAdvancedFocus: true,
errors: {
BAD_REQUEST: {
icon: IconWorldOff,
+1
View File
@@ -5,6 +5,7 @@ import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("tracearr", {
icon: IconActivityHeartbeat,
supportsAdvancedFocus: true,
refetchInterval: 5,
createOptions() {
return optionsBuilder.from((factory) => ({
+1
View File
@@ -5,6 +5,7 @@ import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("traefik", {
icon: IconRoute,
supportsAdvancedFocus: true,
createOptions() {
return optionsBuilder.from((factory) => ({
showTcp: factory.switch({
+1
View File
@@ -8,6 +8,7 @@ export const timeFrameValues = ["today", "24h", "7d", "30d", "month", "lastMonth
export const { definition, componentLoader } = createWidgetDefinition("umami", {
icon: IconChartBar,
supportsAdvancedFocus: true,
queryKeys: [
[["widget", "umami", "getVisitorStats"]],
[["widget", "umami", "getActiveVisitors"]],
+1
View File
@@ -7,6 +7,7 @@ import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("ups", {
icon: IconBatteryCharging,
supportsAdvancedFocus: true,
createOptions() {
return optionsBuilder.from((factory) => ({
showBattery: factory.switch({
@@ -5,6 +5,7 @@ import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("uptimeKuma", {
icon: IconHeartbeat,
supportsAdvancedFocus: true,
createOptions() {
return optionsBuilder.from((factory) => ({
showAverageUptime: factory.switch({
+1
View File
@@ -7,6 +7,7 @@ import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("weather", {
icon: IconCloud,
supportsAdvancedFocus: true,
queryKey: [["widget", "weather", "atLocation"]],
queryMatcher: ({ input }, scope) => {
const location = scope.options.location;
+16 -753
View File
File diff suppressed because it is too large. Load diff
+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