Compare commits

...
Author SHA1 Message Date
ajnart 008b506dfa style: auto-fix lint and format 2026-06-10 16:50:04 +00:00
Thomas Camlong 3b0f9bec1e chore: add query-persist-client-core to catalog and regenerate lockfile
Restore missing @tanstack/query-persist-client-core catalog entry required
by the query cache persister, then regenerate pnpm-lock.yaml from scratch.
2026-06-10 18:48:58 +02:00
Thomas Camlong 7c6706b82b Merge remote-tracking branch 'origin/dev' into feat/global-refresh-indicator 2026-06-10 18:48:06 +02:00
Thomas Camlong 6cd947fc67 chore: update pnpm-lock.yaml after dev merge 2026-06-10 18:46:24 +02:00
Thomas Camlong c8a2be553a refactor(widgets): migrate new integration widgets from useSuspenseQuery to useQuery
Convert Uptime Kuma, Paperless-ngx, audio-stats, and UPS widgets
to use useQuery with WidgetEmptyState fallback, consistent with the
query cache persistence pattern from PR #5811.
2026-06-10 18:45:56 +02:00
Thomas Camlong 80d61f5428 refactor(widgets): migrate Beszel widgets from useSuspenseQuery to useQuery
Convert all 4 Beszel widget components to use useQuery with staleTime
instead of useSuspenseQuery with refetchOnMount, matching the query
cache persistence pattern from PR #5811.
2026-06-10 18:45:56 +02:00
Thomas Camlong a3a26c0c4f chore: regenerate pnpm-lock.yaml 2026-06-10 18:45:55 +02:00
Thomas 29bd7101b1 feat(board): add widget hover overlay on dashboard (#5888) 2026-06-10 18:45:41 +02:00
Thomas Camlong 1079a5a4c5 chore: regenerate pnpm-lock.yaml 2026-06-10 18:32:12 +02:00
Thomas 69e87fd3fe Merge branch 'dev' into feat/global-refresh-indicator 2026-06-10 18:29:58 +02:00
Thomas 6baff7be3d feat: add MCP server for AI assistant integration (#5882) 2026-06-10 18:28:34 +02:00
Thomas 93b2ed88ed Merge branch 'dev' into feat/global-refresh-indicator 2026-06-04 23:09:01 +02:00
Thomas ef27d739bb remove green dot (#5822) 2026-05-29 23:41:40 +02:00
Thomas 9fc0e5f94c Delete .cursor/plans/widget_refresh_indicator_ffb2b58d.plan.md 2026-05-29 22:55:08 +02:00
Thomas 60ae487842 Merge branch 'dev' into feat/global-refresh-indicator 2026-05-29 22:35:05 +02:00
ajnart 4bfde12b65 style: auto-fix lint and format 2026-05-29 17:55:03 +00:00
Thomas Camlong 5899e1b44b chore(deps): add @tanstack/query-persist-client-core to workspace catalog 2026-05-29 17:26:44 +02:00
Thomas Camlong 201a461729 feat(ui): rewrite query refresh indicator with integration icons
Filter out widget.app.ping from the indicator. Show colored status dots
(green/orange/red) per query alongside integration icons with name
tooltips. Use lookup maps and resolver functions instead of ternaries.
Display relative timestamps for last data update.
2026-05-29 17:26:36 +02:00
Thomas Camlong 87798a9789 feat(nextjs): stream cached query data from Redis during SSR
Add QueryCacheHydration async server component inside <Suspense> that
reads all persisted queries from Redis via HGETALL and streams a
HydrationBoundary to the client. Builds DehydratedState directly with
original dataUpdatedAt timestamps so fresh prefetch data is never
overwritten by stale cache. Make client persister write-only (getItem
returns null) since reads are handled server-side.
2026-05-29 17:26:28 +02:00
Thomas Camlong 682f6bd3ea fix(widgets): fix downloads widget and add SSR prefetch
Remove WidgetEmptyState guard from downloads (widget renders fine with
empty data). Fix columnsSort filter logic from .some() to .every() so
non-sortable columns are correctly excluded. Add server-side prefetch
via tRPC caller so download data is available immediately on page load.
2026-05-29 17:26:15 +02:00
Thomas Camlong f49ae79c99 refactor(widgets): stabilize widget components after useQuery migration
Add WidgetEmptyState component for widgets that need a loading fallback.
Disable refetch-on-mount/focus/reconnect and retry for integration widgets
so cached data is preserved. Ensure hooks are never called conditionally.
2026-05-29 17:26:05 +02:00
Thomas Camlong f7f6d1e382 feat(boards): wire board cache scope for query persistence
Call setActiveQueryCacheBoardId when board context initializes so the
client-side persister knows which board to scope cache reads/writes to.
2026-05-29 17:25:53 +02:00
Thomas Camlong 6c623a3a2d feat(redis): migrate query cache to Hash storage for bulk SSR reads
Replace individual Redis keys (queryCache:sha256(...)) with a Hash per
user+board (qc:{userId}:{boardId}). This enables HGETALL to read all
cached entries in a single call for server-side hydration. Remove unused
SHA256 hashKey function and createHash import.
2026-05-29 17:25:46 +02:00
Thomas Camlong b65eef08ea feat(api): add Redis-backed query cache persistence layer
Add tRPC router (queryCache.getItem/setItem/removeItem) for persisting
TanStack Query cache entries to Redis, scoped per user and board.
Includes isPersistableWidgetQueryKey filter that excludes widget.app.ping,
and comprehensive tests covering access control, TTL expiry, and size limits.
2026-05-29 17:25:38 +02:00
ajnart 598307c7bf style: auto-fix lint and format 2026-05-28 16:08:04 +00:00
Thomas Camlong 94cbbde576 fix(widgets): prevent NoIntegrationDataError during initial load and scope indicator
- Add isFetched guard in media-requests/list to avoid throwing before data loads
- Scope refresh indicator to only track widget and integration queries
2026-05-28 18:07:03 +02:00
Thomas Camlong 1b003bfc50 fix(widgets): resolve hooks violations, stale state, and missing edit-mode guard
- Move hooks above early returns in system-disks, immich-carousel, media-transcoding
- Add enabled flag to immich-carousel query when albumId is absent
- Replace disconnected local state in ping-indicator with query cache setData
- Stabilize docker timestamp with useMemo to prevent useTimeAgo flicker
- Wrap releases results in useMemo with proper type narrowing
- Hide refresh indicator during edit mode
2026-05-28 18:07:03 +02:00
Thomas Camlong 394debbea1 refactor(widgets): migrate all widgets from useSuspenseQuery to useQuery
Non-blocking data fetching for all 38 widget components. Widgets now
render immediately and show data when available, with loading state
delegated to the global refresh indicator in the board header.
2026-05-28 18:07:03 +02:00
Thomas Camlong cb5ae65f8a refactor(ui): simplify indicator to status dot on board logo
Use Mantine Indicator with processing animation instead of custom CSS.
Switch from HoverCard to Menu with grouped sections. Remove beep,
position dot next to board logo, hide in edit mode.
2026-05-28 18:07:03 +02:00
ajnart fc80a61aef style: auto-fix lint and format 2026-05-28 14:07:20 +00:00
Thomas Camlong a2668740c0 feat(ui): add global query refresh indicator to board header
Replace per-widget loading spinners with a shared header indicator that
shows live TanStack Query cache status for all widget-related queries.
Includes orange/green dot, completion beep, and hover card with
per-query staleness and error details.
2026-05-28 16:05:49 +02:00
117 changed files with 6936 additions and 12813 deletions

No files matched your search

@@ -0,0 +1,197 @@
---
name: Revert Demo MCP
overview: Revert the demo-specific MCP accommodations (API key bypass, read-only filtering, demo UI) since demo MCP is an edge case with poor ROI. Replace with a clear "MCP not available in demo mode" message. Keep the core MCP feature intact for real instances.
todos:
- id: revert-backend
content: Revert demoSafe procedure, apiKeys demo bypass, isDemoMode export from mcp.ts
status: pending
- id: revert-mcp-route
content: Remove isDemoMode filtering and demo SERVER_INSTRUCTIONS from MCP route
status: pending
- id: revert-ui
content: Remove isDemoMode prop plumbing; add demo-not-supported Alert via info.isDemoMode
status: pending
- id: verify
content: Run lint + typecheck; smoke-test demo vs non-demo MCP tab
status: pending
isProject: false
---
# Revert Demo MCP Accommodations
## Why Docker works but integrations do not
Demo mode has **two different mock mechanisms**:
```mermaid
flowchart TB
subgraph dockerPath [Docker - works in demo]
MCP1[MCP docker_getContainers]
Handler[docker.ts request handler]
MockContainers[Hardcoded mockContainers]
MCP1 --> Handler
Handler -->|"if DEMO_MODE"| MockContainers
end
subgraph integrationPath [Integrations - confusing in demo]
MCP2[MCP widget tools e.g. calendar_findAllEvents]
TRPC[tRPC procedure]
RH[cached-integration-request-handler]
Kind{integration.kind}
Mock[MockIntegration fake data]
Real[Sonarr/Radarr HTTP - fails]
MCP2 --> TRPC --> RH --> Kind
Kind -->|mock| Mock
Kind -->|sonarr/radarr| Real
end
```
- **Docker**:
[`packages/request-handler/src/docker.ts`](packages/request-handler/src/docker.ts)
checks `DEMO_MODE` directly and returns inline fake containers. No integration
record needed.
- **Everything else**: Demo seed creates **one** integration with `kind: "mock"`
([`packages/db/migrations/seed.ts`](packages/db/migrations/seed.ts)), not
Sonarr/Radarr/Overseerr.
[`MockIntegration`](packages/integrations/src/mock/mock-integration.ts) can
serve calendar, downloads, health, DNS, media server, etc. **if** callers pass
that integration ID.
- **Why it felt broken**: MCP tool descriptions tell AI clients to look for
"Overseerr/Sonarr IDs". Demo has none. Tools like
`integration_searchMediaRequests` hard-filter to `mediaSearch` kinds
(`overseerr`, `jellyseerr`, `seerr`) — `mock` is excluded from that category
in
[`packages/definitions/src/integration.ts`](packages/definitions/src/integration.ts).
Fixing this properly means extending the mock integration system (new
`searchAsync`, category changes, middleware relaxations, instruction rewrites)
for a feature almost nobody will use on the public demo.
## Option comparison
| | **Revert (chosen)** | **Minimal fix** | **Full fix** |
| -------------------- | --------------------------- | ---------------------------------------------- | --------------------------------------------------- |
| **Effort** | ~1 hour, ~100 lines removed | ~2 hours | 1-2 days |
| **Files touched** | 8 MCP/demo-specific files | 8 + route instructions | 15+ across integrations, definitions, seed, routers |
| **Risk** | Low | Low-medium | Medium (mock/search semantics) |
| **Demo MCP outcome** | Clearly unsupported | Partial (widget reads only, no search/request) | Near-full read coverage |
| **Maintenance** | None ongoing | Instructions drift with tool changes | Mock parity with real integrations |
**Recommendation: Revert.** The demo accommodation code added special-case
plumbing (`demoSafePermissionRequiredProcedure`, dual mutation guards, hardcoded
UI strings) for a workflow that still cannot deliver a convincing "homelab AI"
experience without substantial integration-layer work.
## What to revert (demo MCP accommodations only)
These changes were added specifically for demo + MCP. **Do not revert** the core
MCP server, OAuth, Scalar docs, tool descriptions, or `normalizeSchema`.
### Backend (3 files)
1. [`packages/api/src/trpc.ts`](packages/api/src/trpc.ts)
- Remove `demoSafePermissionRequiredProcedure` export (~12 lines)
- Keep existing `enforceDemoModeReadOnly` (pre-dates MCP; used by whole app)
2. [`packages/api/src/router/apiKeys.ts`](packages/api/src/router/apiKeys.ts)
- Change `create` back to `permissionRequiredProcedure` (mutations blocked in
demo again)
- Remove `isDemoMode` guard on `delete` (global demo middleware already
blocks delete mutations)
- Remove `TRPCError` / `isDemoMode` imports if unused
3. [`packages/api/src/mcp.ts`](packages/api/src/mcp.ts)
- Stop exporting `isDemoMode`; keep only `createTRPCContext` and `mcpRouter`
### MCP route (1 file)
4. [`apps/nextjs/src/app/api/mcp/[transport]/route.ts`](apps/nextjs/src/app/api/mcp/[transport]/route.ts)
- Remove `isDemoMode` import and all demo branches:
- `ListToolsRequestSchema` mutation filter
- `CallToolRequestSchema` mutation rejection
- `SERVER_INSTRUCTIONS` demo section
- Keep `getToolType` / `buildToolTypeMap` only if still needed elsewhere;
otherwise remove dead code from demo-only filtering
### UI (4 files)
5. [`apps/nextjs/src/app/[locale]/manage/tools/api/page.tsx`](apps/nextjs/src/app/[locale]/manage/tools/api/page.tsx)
- Remove `isDemoMode` import from `@homarr/api/mcp`
- Stop passing `isDemoMode` to child components
6. [`apps/nextjs/src/app/[locale]/manage/tools/api/components/api-page-tabs.tsx`](apps/nextjs/src/app/[locale]/manage/tools/api/components/api-page-tabs.tsx)
- Remove `isDemoMode` prop from interface and `McpInstructions` call
7. [`apps/nextjs/src/app/[locale]/manage/tools/api/components/mcp-instructions.tsx`](apps/nextjs/src/app/[locale]/manage/tools/api/components/mcp-instructions.tsx)
- Remove `isDemoMode` prop
- Replace current demo banner with a single **Alert**: MCP requires a
self-hosted instance; demo mode does not support MCP (API key creation and
write tools are disabled)
- Use `clientApi.info.isDemoMode.useQuery()` or pass `isDemoMode` from server
via existing `api.info.isDemoMode()` tRPC query (already exists in
[`packages/api/src/router/info.ts`](packages/api/src/router/info.ts)) —
avoids re-exporting from `@homarr/api/mcp`
8. [`apps/nextjs/src/app/[locale]/manage/tools/api/components/api-keys.tsx`](apps/nextjs/src/app/[locale]/manage/tools/api/components/api-keys.tsx)
- Remove `isDemoMode` prop and conditional delete-column spread
- Delete button hidden automatically in demo because `apiKeys.delete`
mutation is blocked by global `enforceDemoModeReadOnly`
## What to add instead
- **MCP tab**: When `info.isDemoMode` is true, show a prominent notice at the
top of
[`mcp-instructions.tsx`](apps/nextjs/src/app/[locale]/manage/tools/api/components/mcp-instructions.tsx)
explaining MCP is for self-hosted instances only.
- **Optional**: Disable/hide the "Create API Key" button on the MCP tab in demo
(mutations will fail anyway).
- **No changes** to integrations, definitions, seed, or mock services.
## Files NOT impacted by revert
Core MCP feature stays as-is:
- [`apps/nextjs/src/app/api/mcp/[transport]/route.ts`](apps/nextjs/src/app/api/mcp/[transport]/route.ts)
— auth, rate limiting, `normalizeSchema`, `SERVER_INSTRUCTIONS` (minus demo
section)
- OAuth routes under `apps/nextjs/src/app/api/mcp/oauth/`
- All MCP-enabled routers in `packages/api/src/router/`
- [`packages/api/src/mcp.ts`](packages/api/src/mcp.ts) — eager router (minus
`isDemoMode` export)
- Scalar UI, dynamic tools table,
[`.cursor/rules/mcp-integration.mdc`](.cursor/rules/mcp-integration.mdc)
Pre-existing demo infrastructure (unchanged):
- [`packages/request-handler/src/docker.ts`](packages/request-handler/src/docker.ts)
— Docker mock containers
- [`packages/db/migrations/seed.ts`](packages/db/migrations/seed.ts) — mock
integration + demo board
- [`packages/integrations/src/mock/`](packages/integrations/src/mock/) — mock
services for widgets on demo board UI
## Verification
1. `pnpm lint` and `pnpm typecheck` pass
2. Demo mode (`DEMO_MODE=true`):
- MCP tab shows "not supported" notice
- API key create fails with existing "Mutations are disabled in demo mode"
- `docker_getContainers` still works on demo board UI (unchanged; separate
code path)
3. Non-demo mode: MCP unchanged (all tools listed, mutations work with
permissions)
## Commit strategy
Single focused commit on `feat/add-mcp-server`:
```
revert(mcp): drop demo-mode accommodations for MCP
MCP is intended for self-hosted instances. Remove demo-specific API key
bypass, read-only tool filtering, and demo UI plumbing. Show a clear
notice on the MCP tab when demo mode is active.
```
Then push to the existing PR (no force-push needed unless you want another
squash).
+103
View File
@@ -0,0 +1,103 @@
---
description: How to expose tRPC procedures as MCP tools for AI clients
globs: packages/api/src/router/**/*.ts
alwaysApply: false
---
# MCP Integration Guide
Every new tRPC procedure that provides useful functionality should be exposed as an MCP tool so AI assistants (Claude, Cursor, etc.) can use it. This is a first-class feature of Homarr.
## Adding MCP to a procedure
Add `.meta({ mcp: { enabled: true, description: "..." } })` to the procedure chain:
```typescript
// Read-only tool (query)
myProcedure: protectedProcedure
.meta({ mcp: { enabled: true, description: "Get all items with their status and metadata" } })
.input(z.object({ limit: z.number().default(50) }))
.query(async ({ ctx, input }) => { ... })
// Write tool (mutation)
myAction: protectedProcedure
.meta({ mcp: { enabled: true, description: "Delete an item by ID. Requires interact permission on the integration" } })
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => { ... })
// No-input procedure (z.void) — works fine, no .input() needed
getAll: protectedProcedure
.meta({ mcp: { enabled: true, description: "List all resources" } })
.query(async ({ ctx }) => { ... })
```
The `.meta()` call must come **before** `.input()` and `.query()`/`.mutation()` in the chain.
## Writing good descriptions
The description is the **only thing** an AI sees to decide when and how to use the tool. Write it like you're explaining to a colleague who has never used Homarr.
**Must include:**
- What the tool returns or does
- Which integrations/services it works with (if applicable)
- How to get required IDs (e.g., "Use integration_all to get the integrationId")
- Permission requirements (if non-obvious)
**Good:**
```
"Get calendar events for upcoming and recent media releases. Fetches from all connected Sonarr (TV), Radarr (movies), Lidarr (music), and Readarr (books) integrations. Requires integrationIds from integration_all"
```
**Bad:**
```
"Get calendar events"
```
**For tools that return permission fields**, explain what they mean:
```
"List all integrations. Returns permissions.hasUseAccess (read) and permissions.hasInteractAccess (actions) — false means the API key lacks that permission, not an error"
```
## Registering in the MCP router
After adding `.meta()` to procedures, you must register the router in `packages/api/src/mcp.ts`. This file uses **eager imports** (no `lazy()`) because MCP tool extraction requires synchronous access to all procedure definitions.
```typescript
// packages/api/src/mcp.ts
import { myNewRouter } from "./router/my-new-feature";
export const mcpRouter = createTRPCRouter({
// ... existing routers
myFeature: myNewRouter,
});
```
**If your router is nested** (e.g., widget sub-routers), import and register the sub-router directly — do not import a parent that uses `lazy()`.
## What to expose vs. what to skip
**Expose:**
- Data queries (list, search, get by ID)
- Actions users would ask an AI to do (toggle, create, delete, approve)
- Status checks (health, stats, summaries)
**Skip:**
- Subscriptions (WebSocket-only, not supported by MCP)
- Internal procedures (session management, onboarding steps)
- File upload/download procedures
- Procedures that return non-serializable data (streams, blobs)
## Input schema rules
- Use `z.object({...})` for inputs — this maps cleanly to MCP tool parameters
- `z.void()` (no `.input()`) works — the MCP handler patches these to accept empty objects
- Avoid `z.union()` or `z.discriminatedUnion()` at the top level — AI clients struggle with these
- Use `.default()` on optional fields so the AI doesn't need to guess values
## Checklist for new features
1. Add `.meta({ mcp: { enabled: true, description: "..." } })` to relevant procedures
2. Import and register the router in `packages/api/src/mcp.ts` (eager, no lazy)
3. Write descriptions that explain what the tool does, what it needs, and what it returns
4. If the tool requires an ID from another tool, say which one in the description
5. Test with `curl` against `/api/mcp/mcp` to verify the tool appears in `tools/list`
+3
View File
@@ -0,0 +1,3 @@
[tools]
node = "24.16.0"
pnpm = "10.33.4"
+5 -2
View File
@@ -56,7 +56,10 @@
"@mantine/hooks": "catalog:",
"@mantine/modals": "catalog:",
"@mantine/tiptap": "catalog:",
"@modelcontextprotocol/sdk": "catalog:",
"@scalar/api-reference-react": "catalog:",
"@tabler/icons-react": "catalog:",
"@tanstack/query-persist-client-core": "catalog:",
"@tanstack/react-query": "catalog:",
"@tanstack/react-query-devtools": "catalog:",
"@tanstack/react-query-next-experimental": "catalog:",
@@ -83,6 +86,7 @@
"lucide-react": "catalog:",
"mantine-datatable": "catalog:",
"mantine-react-table": "catalog:",
"mcp-handler": "catalog:",
"next": "catalog:",
"postcss-preset-mantine": "catalog:",
"prismjs": "catalog:",
@@ -93,7 +97,7 @@
"sass": "catalog:",
"sql.js": "catalog:",
"superjson": "catalog:",
"swagger-ui-react": "catalog:",
"trpc-to-mcp": "catalog:",
"use-deep-compare-effect": "catalog:",
"yaml": "catalog:",
"zod": "catalog:"
@@ -107,7 +111,6 @@
"@types/prismjs": "catalog:",
"@types/react": "catalog:",
"@types/react-dom": "catalog:",
"@types/swagger-ui-react": "catalog:",
"concurrently": "catalog:",
"node-loader": "catalog:",
"typescript": "catalog:"
@@ -0,0 +1,32 @@
import { headers } from "next/headers";
import { extractBaseUrlFromHeaders } from "@homarr/common";
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, OPTIONS",
"Access-Control-Allow-Headers": "*",
};
export async function GET() {
const baseUrl = extractBaseUrlFromHeaders(await headers());
return Response.json(
{
issuer: baseUrl,
authorization_endpoint: `${baseUrl}/api/mcp/oauth/authorize`,
token_endpoint: `${baseUrl}/api/mcp/oauth/token`,
registration_endpoint: `${baseUrl}/api/mcp/oauth/register`,
response_types_supported: ["code"],
grant_types_supported: ["authorization_code"],
code_challenge_methods_supported: ["S256"],
token_endpoint_auth_methods_supported: ["none"],
scopes_supported: ["mcp:tools"],
},
{ headers: corsHeaders },
);
}
export async function OPTIONS() {
return new Response(null, { status: 204, headers: corsHeaders });
}
@@ -0,0 +1,25 @@
import { headers } from "next/headers";
import { extractBaseUrlFromHeaders } from "@homarr/common";
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, OPTIONS",
"Access-Control-Allow-Headers": "*",
};
export async function GET() {
const baseUrl = extractBaseUrlFromHeaders(await headers());
return Response.json(
{
resource: baseUrl,
authorization_servers: [baseUrl],
},
{ headers: corsHeaders },
);
}
export async function OPTIONS() {
return new Response(null, { status: 204, headers: corsHeaders });
}
@@ -0,0 +1,49 @@
import type { AsyncStorage, PersistedQuery } from "@tanstack/query-persist-client-core";
import { experimental_createQueryPersister } from "@tanstack/query-persist-client-core";
import { parse, stringify } from "superjson";
import { fetchApi } from "@homarr/api/client";
import {
getActiveQueryCacheBoardId,
isPersistableWidgetQueryKey,
queryCacheStoragePrefix,
} from "@homarr/api/query-cache";
const queryCacheStorage = {
async getItem(_key) {
return null;
},
async setItem(key, value) {
const boardId = getActiveQueryCacheBoardId();
if (!boardId) return;
try {
await fetchApi.queryCache.setItem.mutate({ boardId, key, value });
} catch {
// Query persistence is best-effort; failed writes should not affect widget rendering.
}
},
async removeItem(key) {
const boardId = getActiveQueryCacheBoardId();
if (!boardId) return;
try {
await fetchApi.queryCache.removeItem.mutate({ boardId, key });
} catch {
// Query persistence is best-effort; failed removals should not affect widget rendering.
}
},
} satisfies AsyncStorage<string>;
export const createWidgetQueryPersister = () =>
experimental_createQueryPersister<string>({
storage: typeof window === "undefined" ? undefined : queryCacheStorage,
maxAge: Number.POSITIVE_INFINITY,
prefix: queryCacheStoragePrefix,
refetchOnRestore: true,
serialize: (query) => stringify(query),
deserialize: (value) => parse<PersistedQuery>(value),
filters: {
predicate: (query) => isPersistableWidgetQueryKey(query.queryKey),
},
});
@@ -21,10 +21,13 @@ import { TRPCClientError } from "@trpc/client";
import type { AppRouter } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { queryCacheDefaultStaleTimeMs } from "@homarr/api/query-cache";
import { createHeadersCallbackForSource, getTrpcUrl } from "@homarr/api/shared";
import { env } from "@homarr/common/env";
import { showWarningNotification } from "@homarr/notifications";
import { createWidgetQueryPersister } from "./query-cache-persister";
const getWebSocketProtocol = () => {
// window is not defined on server side
if (typeof window === "undefined") {
@@ -52,12 +55,14 @@ const wsClient = createWSClient({
});
export function TRPCReactProvider(props: PropsWithChildren) {
const [queryPersister] = useState(() => createWidgetQueryPersister());
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
staleTime: 5 * 1000,
persister: queryPersister.persisterFn,
staleTime: queryCacheDefaultStaleTimeMs,
},
mutations: {
onError(error) {
@@ -1,10 +1,13 @@
import type { Metadata } from "next";
import { Suspense } from "react";
import { TRPCError } from "@trpc/server";
// Placed here because gridstack styles are used for board content
import "~/styles/gridstack.scss";
import type { DehydratedState } from "@tanstack/react-query";
import { dehydrate, HydrationBoundary } from "@tanstack/react-query";
import { parse } from "superjson";
import { getQueryClient } from "@homarr/api/server";
import { IntegrationProvider } from "@homarr/auth/client";
@@ -14,6 +17,7 @@ import { isNullOrWhitespace } from "@homarr/common";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
import type { WidgetKind } from "@homarr/definitions";
import { getAllQueryCacheAsync } from "@homarr/redis";
import { getI18n } from "@homarr/translation/server";
import { prefetchForKindAsync } from "@homarr/widgets/prefetch";
@@ -72,9 +76,14 @@ export const createBoardContentPage = <TParams extends Record<string, unknown>>(
),
]);
const userId = session?.user.id ?? "anonymous";
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<IntegrationProvider integrations={integrations}>
<Suspense>
<QueryCacheHydration userId={userId} boardId={board.id} />
</Suspense>
<DynamicClientBoard />
</IntegrationProvider>
</HydrationBoundary>
@@ -108,3 +117,37 @@ export const createBoardContentPage = <TParams extends Record<string, unknown>>(
},
};
};
interface PersistedEntry {
state: { data: unknown; dataUpdatedAt: number; status: string };
queryKey: unknown[];
queryHash: string;
}
async function QueryCacheHydration({ userId, boardId }: { userId: string; boardId: string }) {
try {
const entries = await getAllQueryCacheAsync(userId, boardId);
const queries: DehydratedState["queries"] = [];
for (const serialized of Object.values(entries)) {
try {
const persisted = parse<PersistedEntry>(serialized);
if (persisted.state.status !== "success") continue;
queries.push({
state: persisted.state as DehydratedState["queries"][number]["state"],
queryKey: persisted.queryKey,
queryHash: persisted.queryHash,
});
} catch {
// skip malformed entries
}
}
if (queries.length === 0) return null;
return <HydrationBoundary state={{ mutations: [], queries }} />;
} catch {
return null;
}
}
@@ -0,0 +1,58 @@
"use client";
import { useState } from "react";
import type { ReactNode } from "react";
import { Tabs, TabsList, TabsPanel, TabsTab } from "@mantine/core";
import { IconBrain, IconCode, IconKey } from "@tabler/icons-react";
import { McpInstructions } from "./mcp-instructions";
export interface McpToolGroup {
namespace: string;
tools: { name: string; description: string; type: "query" | "mutation" }[];
}
interface ApiPageTabsProps {
documentationLabel: string;
apiKeyLabel: string;
mcpLabel: string;
documentationPanel: ReactNode;
authenticationPanel: ReactNode;
baseUrl: string;
hasApiKeys: boolean;
toolGroups: McpToolGroup[];
}
export function ApiPageTabs({
documentationLabel,
apiKeyLabel,
mcpLabel,
documentationPanel,
authenticationPanel,
baseUrl,
hasApiKeys,
toolGroups,
}: ApiPageTabsProps) {
const [activeTab, setActiveTab] = useState<string | null>("documentation");
return (
<Tabs value={activeTab} onChange={setActiveTab}>
<TabsList>
<TabsTab value="documentation" leftSection={<IconCode size={16} />}>
{documentationLabel}
</TabsTab>
<TabsTab value="authentication" leftSection={<IconKey size={16} />}>
{apiKeyLabel}
</TabsTab>
<TabsTab value="mcp" leftSection={<IconBrain size={16} />}>
{mcpLabel}
</TabsTab>
</TabsList>
<TabsPanel value="authentication">{authenticationPanel}</TabsPanel>
<TabsPanel value="documentation">{documentationPanel}</TabsPanel>
<TabsPanel value="mcp">
<McpInstructions baseUrl={baseUrl} hasApiKeys={hasApiKeys} toolGroups={toolGroups} />
</TabsPanel>
</Tabs>
);
}
@@ -0,0 +1,343 @@
"use client";
import {
Accordion,
AccordionControl,
AccordionItem,
AccordionPanel,
ActionIcon,
Alert,
Badge,
Button,
Code,
CopyButton,
Group,
List,
ListItem,
Stack,
Text,
ThemeIcon,
Title,
} from "@mantine/core";
import {
IconAlertTriangle,
IconBrain,
IconCheck,
IconCopy,
IconKey,
IconLock,
IconPlus,
IconPlugConnected,
IconSparkles,
IconTerminal,
IconTools,
IconWorld,
} from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useModalAction } from "@homarr/modals";
import { useScopedI18n } from "@homarr/translation/client";
import { CopyApiKeyModal } from "./copy-api-key-modal";
import type { McpToolGroup } from "./api-page-tabs";
import classes from "./mcp-tools-accordion.module.css";
const toolTypeDisplay: Record<"query" | "mutation", { color: string; method: string }> = {
query: { color: "blue", method: "GET" },
mutation: { color: "orange", method: "POST" },
};
interface McpInstructionsProps {
baseUrl: string;
hasApiKeys: boolean;
toolGroups: McpToolGroup[];
}
export function McpInstructions({ baseUrl, hasApiKeys, toolGroups }: McpInstructionsProps) {
const t = useScopedI18n("management.page.tool.api.tab.mcp");
const { openModal } = useModalAction(CopyApiKeyModal);
const { mutate: createApiKey, isPending } = clientApi.apiKeys.create.useMutation({
async onSuccess(data) {
openModal({ apiKey: data.apiKey });
await revalidatePathActionAsync("/manage/tools/api");
},
});
const mcpUrl = `${baseUrl}/api/mcp/mcp`;
const streamableHttpConfig = JSON.stringify(
{
mcpServers: {
homarr: {
url: mcpUrl,
headers: {
ApiKey: "<your-api-key>",
},
},
},
},
null,
2,
);
const stdioConfig = JSON.stringify(
{
mcpServers: {
homarr: {
command: "npx",
args: ["-y", "mcp-remote", mcpUrl, "--header", "ApiKey:<your-api-key>"],
},
},
},
null,
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}`;
return (
<Stack gap="xl" p="md">
<Group gap="sm" align="center">
<ThemeIcon variant="light" size="xl" radius="md">
<IconBrain size={24} />
</ThemeIcon>
<div>
<Title order={3}>{t("title")}</Title>
<Text size="sm" c="dimmed">
{t("subtitle")}
</Text>
</div>
</Group>
<Alert variant="light" icon={<IconSparkles size={18} />}>
<Text size="sm">{t("description")}</Text>
</Alert>
{!hasApiKeys && (
<Alert variant="light" color="yellow" icon={<IconKey size={18} />}>
<Group justify="space-between" align="center">
<Text size="sm">{t("noApiKey")}</Text>
<Button
size="compact-sm"
variant="filled"
leftSection={<IconPlus size={14} />}
onClick={() => createApiKey()}
loading={isPending}
>
{t("createApiKeyButton")}
</Button>
</Group>
</Alert>
)}
<div>
<Title order={5} mb="xs">
<Group gap="xs">
<IconPlugConnected size={16} />
{t("endpoint.title")}
</Group>
</Title>
<CopyableCode value={mcpUrl} copyLabel={t("copy")} />
</div>
<div>
<Title order={5} mb="xs">
<Group gap="xs">
<IconLock size={16} />
{t("authentication.title")}
</Group>
</Title>
<Text size="sm" mb="sm">
{t("authentication.intro")}
</Text>
<Accordion variant="separated" defaultValue="apikey">
<AccordionItem value="apikey">
<AccordionControl>
<Group gap="xs">
<IconKey size={16} />
{t("authentication.apiKey.title")}
</Group>
</AccordionControl>
<AccordionPanel>
<Stack gap="xs">
<Text size="sm">{t("authentication.apiKey.description")}</Text>
<Text size="xs" c="dimmed">
{t("authentication.permissions")}
</Text>
</Stack>
</AccordionPanel>
</AccordionItem>
<AccordionItem value="oauth">
<AccordionControl>
<Group gap="xs">
<IconWorld size={16} />
{t("authentication.oauth.title")}
</Group>
</AccordionControl>
<AccordionPanel>
<Stack gap="xs">
<Text size="sm">{t("authentication.oauth.description")}</Text>
<Text size="xs" c="dimmed">
{t("authentication.oauth.hint")}
</Text>
</Stack>
</AccordionPanel>
</AccordionItem>
</Accordion>
</div>
<div>
<Title order={5} mb="sm">
{t("clientConfig.title")}
</Title>
<Accordion variant="separated" defaultValue="streamable-http">
<AccordionItem value="streamable-http">
<AccordionControl>
<Group gap="xs">
<Badge size="xs" variant="filled">
{t("clientConfig.streamableHttp.recommended")}
</Badge>
{t("clientConfig.streamableHttp.title")}
</Group>
</AccordionControl>
<AccordionPanel>
<Stack gap="sm">
<Text size="sm" fw={500}>
{t("clientConfig.streamableHttp.worksWith")}
</Text>
<Text size="sm" c="dimmed">
{t("clientConfig.streamableHttp.addToConfig")}
</Text>
<List size="xs" spacing={4}>
<ListItem>
{t("clientConfig.streamableHttp.cursor", {
name: "Cursor",
file: ".cursor/mcp.json",
})}
</ListItem>
<ListItem>
{t("clientConfig.streamableHttp.claudeDesktop", {
name: "Claude Desktop",
file: "claude_desktop_config.json",
})}
</ListItem>
<ListItem>
{t("clientConfig.streamableHttp.claudeCode", {
name: "Claude Code",
file: ".claude/mcp.json",
command: "claude mcp add homarr",
})}
</ListItem>
<ListItem>
{t("clientConfig.streamableHttp.vsCodeCopilot", {
name: "VS Code Copilot",
file: ".vscode/mcp.json",
})}
</ListItem>
</List>
<CopyableCode value={streamableHttpConfig} copyLabel={t("copy")} />
<Alert variant="light" color="yellow" icon={<IconAlertTriangle size={16} />} p="xs">
<Text size="xs">{t("clientConfig.streamableHttp.replaceHint")}</Text>
</Alert>
</Stack>
</AccordionPanel>
</AccordionItem>
<AccordionItem value="stdio">
<AccordionControl>{t("clientConfig.stdio.title")}</AccordionControl>
<AccordionPanel>
<Stack gap="sm">
<Text size="sm" c="dimmed">
{t("clientConfig.stdio.description", { dependency: "npx" })}
</Text>
<CopyableCode value={stdioConfig} copyLabel={t("copy")} />
</Stack>
</AccordionPanel>
</AccordionItem>
</Accordion>
</div>
<div>
<Title order={5} mb="xs">
<Group gap="xs">
<IconTerminal size={16} />
{t("testConnection.title")}
</Group>
</Title>
<Text size="sm" c="dimmed" mb="xs">
{t("testConnection.description")}
</Text>
<CopyableCode value={testCommand} copyLabel={t("copy")} />
<Text size="xs" c="dimmed" mt="xs">
{t("testConnection.hint")}
</Text>
</div>
<div>
<Title order={5} mb="xs">
<Group gap="xs">
<IconTools size={16} />
{t("availableTools.title")}
</Group>
</Title>
<Text size="sm" c="dimmed" mb="sm">
{t("availableTools.description", {
count: String(toolGroups.reduce((sum, g) => sum + g.tools.length, 0)),
})}
</Text>
<Accordion variant="separated" multiple>
{toolGroups.map((group) => (
<AccordionItem key={group.namespace} value={group.namespace}>
<AccordionControl>
<Group gap="xs">
<Text size="sm" fw={600}>
{group.namespace}
</Text>
<Badge size="xs" variant="light" circle>
{group.tools.length}
</Badge>
</Group>
</AccordionControl>
<AccordionPanel>
<Stack gap={6}>
{group.tools.map((tool) => (
<div key={tool.name} className={classes.toolRow}>
<Badge size="sm" w={50} radius="xs" variant="light" color={toolTypeDisplay[tool.type].color}>
{toolTypeDisplay[tool.type].method}
</Badge>
<div>
<Code fz="xs" fw={600}>
{tool.name}
</Code>
<Text size="xs" c="dimmed" lh={1.4}>
{tool.description}
</Text>
</div>
</div>
))}
</Stack>
</AccordionPanel>
</AccordionItem>
))}
</Accordion>
</div>
</Stack>
);
}
function CopyableCode({ value, copyLabel }: { value: string; copyLabel: string }) {
return (
<Code block pos="relative" style={{ whiteSpace: "pre-wrap" }}>
{value}
<CopyButton value={value}>
{({ copied, copy }) => (
<ActionIcon variant="subtle" size="sm" onClick={copy} pos="absolute" top={8} right={8} title={copyLabel}>
{copied ? <IconCheck size={14} /> : <IconCopy size={14} />}
</ActionIcon>
)}
</CopyButton>
</Code>
);
}
@@ -0,0 +1,6 @@
.toolRow {
display: grid;
grid-template-columns: 42px 1fr;
align-items: first baseline;
gap: var(--mantine-spacing-xs);
}
@@ -0,0 +1,35 @@
"use client";
import { useComputedColorScheme } from "@mantine/core";
import { ApiReferenceReact } from "@scalar/api-reference-react";
import "@scalar/api-reference-react/style.css";
import "./scalar-theme.css";
interface ScalarApiReferenceProps {
document: object;
}
export function ScalarApiReference({ document }: ScalarApiReferenceProps) {
const colorScheme = useComputedColorScheme("light");
return (
<ApiReferenceReact
configuration={{
content: document,
layout: "classic",
theme: "alternate",
showSidebar: false,
hideDarkModeToggle: true,
hideSearch: true,
hiddenClients: true,
showDeveloperTools: "never",
defaultOpenAllTags: true,
forceDarkModeState: colorScheme,
authentication: {
preferredSecurityScheme: "apikey",
},
}}
/>
);
}
@@ -0,0 +1,51 @@
.light-mode {
--scalar-background-1: var(--mantine-color-body);
--scalar-background-2: var(--mantine-color-gray-0);
--scalar-background-3: var(--mantine-color-gray-1);
--scalar-background-card: var(--mantine-color-white);
--scalar-background-accent: rgba(0, 0, 0, 0.04);
--scalar-color-1: var(--mantine-color-text);
--scalar-color-2: var(--mantine-color-dimmed);
--scalar-color-3: var(--mantine-color-dimmed);
--scalar-color-accent: var(--mantine-color-red-6);
--scalar-border-color: var(--mantine-color-gray-3);
--scalar-button-1: var(--mantine-color-dark-7);
--scalar-button-1-hover: var(--mantine-color-dark-5);
--scalar-button-1-color: var(--mantine-color-white);
--scalar-scrollbar-color: rgba(0, 0, 0, 0.18);
--scalar-scrollbar-color-active: rgba(0, 0, 0, 0.36);
}
.dark-mode {
--scalar-background-1: var(--mantine-color-body);
--scalar-background-2: var(--mantine-color-dark-6);
--scalar-background-3: var(--mantine-color-dark-5);
--scalar-background-card: var(--mantine-color-dark-6);
--scalar-background-accent: rgba(255, 255, 255, 0.06);
--scalar-color-1: var(--mantine-color-text);
--scalar-color-2: var(--mantine-color-dimmed);
--scalar-color-3: var(--mantine-color-dimmed);
--scalar-color-accent: var(--mantine-color-red-6);
--scalar-border-color: var(--mantine-color-dark-4);
--scalar-button-1: rgba(255, 255, 255, 0.9);
--scalar-button-1-hover: rgba(255, 255, 255, 0.8);
--scalar-button-1-color: var(--mantine-color-dark-7);
--scalar-scrollbar-color: rgba(255, 255, 255, 0.18);
--scalar-scrollbar-color-active: rgba(255, 255, 255, 0.36);
}
.scalar-card,
.scalar-api-client__item,
.dark-mode .dark-mode.scalar-card {
--scalar-background-1: var(--scalar-background-card);
--scalar-background-2: var(--scalar-background-card);
--scalar-background-3: var(--scalar-background-card);
}
@@ -1,23 +0,0 @@
"use client";
import type { OpenAPIObject } from "openapi3-ts/oas31";
import SwaggerUI from "swagger-ui-react";
// workaround for CSS that cannot be processed by next.js, https://github.com/swagger-api/swagger-ui/issues/10045
import "../swagger-ui-dark.css";
import "../swagger-ui-overrides.css";
import "../swagger-ui.css";
interface SwaggerUIClientProps {
document: OpenAPIObject;
}
export const SwaggerUIClient = ({ document }: SwaggerUIClientProps) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const requestInterceptor = (req: Record<string, any>) => {
req.credentials = "omit";
return req;
};
return <SwaggerUI requestInterceptor={requestInterceptor} spec={document} />;
};
@@ -1,16 +1,57 @@
import { headers } from "next/headers";
import { notFound } from "next/navigation";
import { Stack, Tabs, TabsList, TabsPanel, TabsTab } from "@mantine/core";
import { Stack } from "@mantine/core";
import { mcpRouter } from "@homarr/api/mcp";
import { openApiDocument } from "@homarr/api/open-api";
import { api } from "@homarr/api/server";
import { auth } from "@homarr/auth/next";
import { extractBaseUrlFromHeaders } from "@homarr/common";
import { getScopedI18n } from "@homarr/translation/server";
import { SwaggerUIClient } from "~/app/[locale]/manage/tools/api/components/swagger-ui";
import { createMetaTitle } from "~/metadata";
import { extractMcpTools } from "~/app/api/mcp/_extract-tools";
import { ApiKeysManagement } from "./components/api-keys";
import { ApiPageTabs } from "./components/api-page-tabs";
import { ScalarApiReference } from "./components/scalar-api-reference";
import type { McpToolGroup } from "./components/api-page-tabs";
let cachedToolGroups: McpToolGroup[] | null = null;
function getMcpToolGroups(): McpToolGroup[] {
if (cachedToolGroups) return cachedToolGroups;
const tools = extractMcpTools();
const procedures = (mcpRouter as any)._def.procedures as Record<string, { _def?: { type?: string } }>;
const procedureTypeMap = new Map<string, "query" | "mutation">();
for (const [key, proc] of Object.entries(procedures)) {
const type = proc?._def?.type;
if (type === "query" || type === "mutation") {
procedureTypeMap.set(key, type);
}
}
const groups = new Map<string, McpToolGroup["tools"]>();
for (const tool of tools) {
const namespace = tool.pathInRouter[0] ?? "other";
if (!groups.has(namespace)) {
groups.set(namespace, []);
}
const procedureKey = tool.pathInRouter.join(".");
groups.get(namespace)?.push({
name: tool.name,
description: tool.description,
type: procedureTypeMap.get(procedureKey) ?? "query",
});
}
cachedToolGroups = Array.from(groups.entries()).map(([namespace, items]) => ({
namespace,
tools: items,
}));
return cachedToolGroups;
}
export async function generateMetadata() {
const session = await auth();
@@ -30,24 +71,25 @@ export default async function ApiPage() {
if (!session?.user.permissions.includes("admin")) {
notFound();
}
const document = openApiDocument(extractBaseUrlFromHeaders(await headers()));
const requestHeaders = await headers();
const baseUrl = extractBaseUrlFromHeaders(requestHeaders);
const document = openApiDocument(baseUrl);
const apiKeys = await api.apiKeys.getAll();
const t = await getScopedI18n("management.page.tool.api.tab");
const toolGroups = getMcpToolGroups();
return (
<Stack>
<Tabs defaultValue={"documentation"}>
<TabsList>
<TabsTab value={"documentation"}>{t("documentation.label")}</TabsTab>
<TabsTab value={"authentication"}>{t("apiKey.label")}</TabsTab>
</TabsList>
<TabsPanel value={"authentication"}>
<ApiKeysManagement apiKeys={apiKeys} />
</TabsPanel>
<TabsPanel value={"documentation"}>
<SwaggerUIClient document={document} />
</TabsPanel>
</Tabs>
<ApiPageTabs
documentationLabel={t("documentation.label")}
apiKeyLabel={t("apiKey.label")}
mcpLabel={t("mcp.label")}
documentationPanel={<ScalarApiReference document={document} />}
authenticationPanel={<ApiKeysManagement apiKeys={apiKeys} />}
baseUrl={baseUrl}
hasApiKeys={apiKeys.length > 0}
toolGroups={toolGroups}
/>
</Stack>
);
}
File diff suppressed because it is too large. Load diff
@@ -1,4 +0,0 @@
.swagger-ui .info {
margin: 0 !important;
margin-bottom: 20px !important;
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,300 @@
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, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
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 { 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");
}
const callerStorage = new AsyncLocalStorage<ReturnType<typeof mcpRouter.createCaller>>();
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;
}
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.
**"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(ListToolsRequestSchema, () => ({
tools: getTools(),
}));
server.server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params;
const tools = getTools();
const tool = tools.find((t) => t.name === name);
if (!tool) {
return {
content: [{ type: "text" as const, text: `Tool "${name}" not found` }],
isError: true,
};
}
const caller = callerStorage.getStore();
if (!caller) {
return {
content: [{ type: "text" as const, text: "Authentication context not available" }],
isError: true,
};
}
try {
const procedure = tool.pathInRouter.reduce<any>((acc, part) => acc?.[part], 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,
error: error instanceof Error ? error.message : String(error),
});
return {
content: [{ type: "text" as const, text: JSON.stringify({ error: message }) }],
isError: true,
};
}
});
},
{
capabilities: { tools: {} },
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, () => mcpHandler(req as unknown as Request));
};
export { handler as GET, handler as POST, handler as DELETE };
@@ -0,0 +1,23 @@
import { extractToolsFromProcedures } from "trpc-to-mcp";
import { mcpRouter } from "@homarr/api/mcp";
let cache: ReturnType<typeof extractToolsFromProcedures> | 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;
}
return cache;
}
+157
View File
@@ -0,0 +1,157 @@
import { createHash, randomBytes, timingSafeEqual } from "crypto";
interface OAuthClient {
clientId: string;
clientSecret: string;
redirectUris: string[];
clientName: string;
createdAt: number;
}
interface AuthCode {
code: string;
clientId: string;
userId: string;
codeChallenge: string;
codeChallengeMethod: string;
redirectUri: string;
expiresAt: number;
}
interface PendingAuth {
clientId: string;
redirectUri: string;
codeChallenge: string;
codeChallengeMethod: string;
state: string | null;
expiresAt: number;
}
interface OAuthStore {
clients: Map<string, OAuthClient>;
authCodes: Map<string, AuthCode>;
pendingAuths: Map<string, PendingAuth>;
cleanupTimer: ReturnType<typeof setInterval> | null;
}
declare global {
var mcpOAuthStore: OAuthStore | undefined;
}
const store =
globalThis.mcpOAuthStore ??
(globalThis.mcpOAuthStore = {
clients: new Map(),
authCodes: new Map(),
pendingAuths: new Map(),
cleanupTimer: null,
});
const clients = store.clients;
const authCodes = store.authCodes;
const pendingAuths = store.pendingAuths;
const CLEANUP_INTERVAL_MS = 60_000;
const AUTH_CODE_TTL_MS = 300_000;
const PENDING_AUTH_TTL_MS = 600_000;
const CLIENT_TTL_MS = 24 * 60 * 60 * 1000;
const MAX_CLIENTS = 1000;
const MAX_AUTH_CODES = 5000;
const MAX_PENDING_AUTHS = 5000;
if (!store.cleanupTimer) {
store.cleanupTimer = setInterval(() => {
const now = Date.now();
for (const [key, code] of authCodes) {
if (code.expiresAt < now) authCodes.delete(key);
}
for (const [key, pending] of pendingAuths) {
if (pending.expiresAt < now) pendingAuths.delete(key);
}
for (const [key, client] of clients) {
if (client.createdAt + CLIENT_TTL_MS < now) clients.delete(key);
}
}, CLEANUP_INTERVAL_MS);
}
export function registerClient(redirectUris: string[], clientName: string) {
if (clients.size >= MAX_CLIENTS) {
return null;
}
const clientId = randomBytes(16).toString("hex");
const clientSecret = randomBytes(32).toString("hex");
const client: OAuthClient = {
clientId,
clientSecret,
redirectUris,
clientName,
createdAt: Date.now(),
};
clients.set(clientId, client);
return { clientId, clientSecret };
}
export function getClient(clientId: string) {
return clients.get(clientId) ?? null;
}
export function createAuthCode(
clientId: string,
userId: string,
codeChallenge: string,
codeChallengeMethod: string,
redirectUri: string,
) {
if (authCodes.size >= MAX_AUTH_CODES) {
return null;
}
const code = randomBytes(32).toString("hex");
authCodes.set(code, {
code,
clientId,
userId,
codeChallenge,
codeChallengeMethod,
redirectUri,
expiresAt: Date.now() + AUTH_CODE_TTL_MS,
});
return code;
}
export function consumeAuthCode(code: string) {
const authCode = authCodes.get(code);
if (!authCode) return null;
authCodes.delete(code);
if (authCode.expiresAt < Date.now()) return null;
return authCode;
}
export function verifyPkce(codeVerifier: string, codeChallenge: string, method: string) {
if (method !== "S256") return false;
const computed = createHash("sha256").update(codeVerifier).digest("base64url");
if (computed.length !== codeChallenge.length) return false;
return timingSafeEqual(Buffer.from(computed), Buffer.from(codeChallenge));
}
export function storePendingAuth(params: Omit<PendingAuth, "expiresAt">) {
if (pendingAuths.size >= MAX_PENDING_AUTHS) {
return null;
}
const id = randomBytes(16).toString("hex");
pendingAuths.set(id, {
...params,
expiresAt: Date.now() + PENDING_AUTH_TTL_MS,
});
return id;
}
export function consumePendingAuth(id: string) {
const pending = pendingAuths.get(id);
if (!pending) return null;
pendingAuths.delete(id);
if (pending.expiresAt < Date.now()) return null;
return pending;
}
@@ -0,0 +1,143 @@
import type { NextRequest } from "next/server";
import { auth } from "@homarr/auth/next";
import { consumePendingAuth, createAuthCode, getClient, storePendingAuth } from "../_store";
export async function GET(req: NextRequest) {
const url = new URL(req.url);
const session = await auth();
const pendingId = url.searchParams.get("pending");
if (pendingId) {
if (!session?.user) {
return Response.json(
{
error: "access_denied",
error_description: "Login was not completed",
},
{ status: 403 },
);
}
const pending = consumePendingAuth(pendingId);
if (!pending) {
return Response.json(
{
error: "invalid_request",
error_description: "Authorization request expired, please try again",
},
{ status: 400 },
);
}
const code = createAuthCode(
pending.clientId,
session.user.id,
pending.codeChallenge,
pending.codeChallengeMethod,
pending.redirectUri,
);
if (!code) {
return Response.json(
{
error: "server_error",
error_description: "Server is busy, please try again later",
},
{ status: 503 },
);
}
const redirect = new URL(pending.redirectUri);
redirect.searchParams.set("code", code);
if (pending.state) redirect.searchParams.set("state", pending.state);
return Response.redirect(redirect.toString());
}
const clientId = url.searchParams.get("client_id");
const redirectUri = url.searchParams.get("redirect_uri");
const codeChallenge = url.searchParams.get("code_challenge");
const codeChallengeMethod = url.searchParams.get("code_challenge_method") ?? "S256";
const state = url.searchParams.get("state");
if (!clientId || !redirectUri || !codeChallenge) {
return Response.json(
{
error: "invalid_request",
error_description: "Missing required parameters",
},
{ status: 400 },
);
}
if (codeChallengeMethod !== "S256") {
return Response.json(
{
error: "invalid_request",
error_description: "Only S256 code_challenge_method is supported",
},
{ status: 400 },
);
}
const client = getClient(clientId);
if (!client) {
return Response.json(
{
error: "invalid_client",
error_description:
"Unknown client_id — the server was restarted and lost the registration. Please re-run the auth command to re-register.",
},
{ status: 400 },
);
}
if (!client.redirectUris.includes(redirectUri)) {
return Response.json(
{
error: "invalid_request",
error_description: "redirect_uri not registered",
},
{ status: 400 },
);
}
if (session?.user) {
const code = createAuthCode(clientId, session.user.id, codeChallenge, codeChallengeMethod, redirectUri);
if (!code) {
return Response.json(
{
error: "server_error",
error_description: "Server is busy, please try again later",
},
{ status: 503 },
);
}
const redirect = new URL(redirectUri);
redirect.searchParams.set("code", code);
if (state) redirect.searchParams.set("state", state);
return Response.redirect(redirect.toString());
}
const id = storePendingAuth({
clientId,
redirectUri,
codeChallenge,
codeChallengeMethod,
state,
});
if (!id) {
return Response.json(
{
error: "server_error",
error_description: "Server is busy, please try again later",
},
{ status: 503 },
);
}
const loginUrl = new URL("/auth/login", url.origin);
loginUrl.searchParams.set("callbackUrl", `/api/mcp/oauth/authorize?pending=${id}`);
return Response.redirect(loginUrl.toString());
}
@@ -0,0 +1,107 @@
import { registerClient } from "../_store";
interface RateLimitStore {
attempts: Map<string, { count: number; resetAt: number }>;
timer: ReturnType<typeof setInterval> | null;
}
const REGISTER_RATE_WINDOW_MS = 60_000;
const REGISTER_MAX_ATTEMPTS = 5;
declare global {
var mcpRegisterRateLimit: RateLimitStore | undefined;
}
const store =
globalThis.mcpRegisterRateLimit ??
(globalThis.mcpRegisterRateLimit = {
attempts: new Map(),
timer: null,
});
const registerAttempts = store.attempts;
if (!store.timer) {
store.timer = setInterval(() => {
const now = Date.now();
for (const [key, entry] of registerAttempts) {
if (entry.resetAt < now) registerAttempts.delete(key);
}
}, REGISTER_RATE_WINDOW_MS);
store.timer.unref();
}
function getClientIp(req: Request): string {
const forwarded = req.headers.get("x-forwarded-for");
return forwarded?.split(",")[0]?.trim() ?? "unknown";
}
function isValidRedirectUri(uri: string): boolean {
try {
const parsed = new URL(uri);
return parsed.protocol === "http:" || parsed.protocol === "https:";
} catch {
return false;
}
}
export async function POST(req: Request) {
const ip = getClientIp(req);
const now = Date.now();
const entry = registerAttempts.get(ip);
if (entry && entry.resetAt >= now && entry.count >= REGISTER_MAX_ATTEMPTS) {
return Response.json(
{ error: "rate_limited", error_description: "Too many registration attempts, try again later" },
{ status: 429, headers: { "Retry-After": "60" } },
);
}
const existing = registerAttempts.get(ip);
if (!existing || existing.resetAt < now) {
registerAttempts.set(ip, { count: 1, resetAt: now + REGISTER_RATE_WINDOW_MS });
} else {
existing.count++;
}
const body = (await req.json().catch(() => null)) as Record<string, unknown> | null;
if (!body) {
return Response.json({ error: "invalid_request", error_description: "Invalid JSON body" }, { status: 400 });
}
const redirectUris = body.redirect_uris;
const clientName = typeof body.client_name === "string" ? body.client_name.slice(0, 256) : "MCP Client";
if (!Array.isArray(redirectUris) || redirectUris.length === 0 || redirectUris.length > 10) {
return Response.json(
{ error: "invalid_client_metadata", error_description: "redirect_uris must be an array of 1-10 URIs" },
{ status: 400 },
);
}
const validUris = redirectUris.filter((uri): uri is string => typeof uri === "string" && isValidRedirectUri(uri));
if (validUris.length !== redirectUris.length) {
return Response.json(
{ error: "invalid_client_metadata", error_description: "All redirect_uris must be valid http or https URLs" },
{ status: 400 },
);
}
const result = registerClient(validUris, clientName);
if (!result) {
return Response.json(
{ error: "server_error", error_description: "Too many registered clients, please try again later" },
{ status: 503 },
);
}
return Response.json(
{
client_id: result.clientId,
client_secret: result.clientSecret,
redirect_uris: validUris,
client_name: clientName,
token_endpoint_auth_method: "none",
},
{ status: 201 },
);
}
@@ -0,0 +1,143 @@
import { randomBytes } from "crypto";
import { hashPasswordAsync } from "@homarr/auth";
import { generateSecureRandomToken } from "@homarr/common/server";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { db } from "@homarr/db";
import { apiKeys } from "@homarr/db/schema";
import { consumeAuthCode, verifyPkce } from "../_store";
const logger = createLogger({ module: "mcpOAuthToken" });
interface RateLimitStore {
failures: Map<string, { count: number; resetAt: number }>;
timer: ReturnType<typeof setInterval> | null;
}
const TOKEN_RATE_WINDOW_MS = 60_000;
const TOKEN_MAX_FAILURES = 10;
declare global {
var mcpTokenRateLimit: RateLimitStore | undefined;
}
const store =
globalThis.mcpTokenRateLimit ??
(globalThis.mcpTokenRateLimit = {
failures: new Map(),
timer: null,
});
const tokenFailures = store.failures;
if (!store.timer) {
store.timer = setInterval(() => {
const now = Date.now();
for (const [key, entry] of tokenFailures) {
if (entry.resetAt < now) tokenFailures.delete(key);
}
}, TOKEN_RATE_WINDOW_MS);
store.timer.unref();
}
function getClientIp(req: Request): string {
const forwarded = req.headers.get("x-forwarded-for");
return forwarded?.split(",")[0]?.trim() ?? "unknown";
}
function recordTokenFailure(ip: string) {
const now = Date.now();
const existing = tokenFailures.get(ip);
if (!existing || existing.resetAt < now) {
tokenFailures.set(ip, { count: 1, resetAt: now + TOKEN_RATE_WINDOW_MS });
return;
}
existing.count++;
}
export async function POST(req: Request) {
const ip = getClientIp(req);
const now = Date.now();
const entry = tokenFailures.get(ip);
if (entry && entry.resetAt >= now && entry.count >= TOKEN_MAX_FAILURES) {
return Response.json(
{ error: "rate_limited", error_description: "Too many requests, try again later" },
{ status: 429, headers: { "Retry-After": "60" } },
);
}
let params: Record<string, string>;
try {
const body = await req.formData().catch(() => null);
params = body
? (Object.fromEntries(body.entries()) as Record<string, string>)
: ((await req.json()) as Record<string, string>);
} catch {
return Response.json(
{ error: "invalid_request", error_description: "Could not parse request body" },
{ status: 400 },
);
}
const { grant_type, code, code_verifier, redirect_uri } = params;
if (grant_type !== "authorization_code") {
return Response.json({ error: "unsupported_grant_type" }, { status: 400 });
}
if (!code || !code_verifier) {
return Response.json(
{ error: "invalid_request", error_description: "Missing code or code_verifier" },
{ status: 400 },
);
}
if (code.length > 256 || code_verifier.length > 256) {
return Response.json({ error: "invalid_request", error_description: "Parameter too long" }, { status: 400 });
}
const authCode = consumeAuthCode(code);
if (!authCode) {
recordTokenFailure(ip);
return Response.json(
{ error: "invalid_grant", error_description: "Invalid or expired authorization code" },
{ status: 400 },
);
}
if (redirect_uri && redirect_uri !== authCode.redirectUri) {
recordTokenFailure(ip);
return Response.json({ error: "invalid_grant", error_description: "redirect_uri 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 });
}
try {
const id = randomBytes(4).toString("hex");
const randomToken = generateSecureRandomToken(24);
const hashedToken = await hashPasswordAsync(randomToken);
await db.insert(apiKeys).values({
id,
apiKey: hashedToken,
userId: authCode.userId,
});
const accessToken = `${id}.${randomToken}`;
return Response.json({
access_token: accessToken,
token_type: "Bearer",
scope: "mcp:tools",
});
} catch (error) {
logger.error("Failed to create API key during OAuth token exchange", {
error: error instanceof Error ? error.message : String(error),
});
return Response.json({ error: "server_error", error_description: "Failed to issue access token" }, { status: 500 });
}
}
+2 -1
View File
@@ -44,7 +44,8 @@ const handler = auth(async (req) => {
const response = await fetchRequestHandler({
endpoint: trpcPath,
router: appRouter,
req,
// mcp-handler globally augments Request with `auth?: AuthInfo`, conflicting with NextAuth's `auth: Session | null`
req: req as unknown as Request,
createContext: () => createTRPCContext({ session, headers: req.headers }),
onError({ error, path, type }) {
logger.error(new ErrorWithMetadata("tRPC Error occured", { path, type }, { cause: error }));
@@ -0,0 +1,155 @@
import { objectEntries } from "@homarr/common";
import type { IntegrationKind, WidgetKind } from "@homarr/definitions";
import type { SettingsContextProps } from "@homarr/settings/creator";
import type { WidgetOptionDefinition, WidgetOptionType } from "@homarr/widgets";
import { reduceWidgetOptionsWithDefaultValues, widgetImports } from "@homarr/widgets";
import type { SectionItem } from "~/app/[locale]/boards/_types";
export type Translator = {
(key: string): string;
};
export interface HoverMetadataItem {
label: string;
value: string;
}
const maxMetadataItems = 3;
const skippedOptionTypes: Partial<Record<WidgetOptionType, true>> = {
app: true,
anchorNote: true,
dynamicSelect: true,
sortableItemList: true,
multiReleasesRepositories: true,
umamiEventName: true,
umamiEventNames: true,
umamiWebsite: true,
};
const isEqualValue = (left: unknown, right: unknown) => JSON.stringify(left) === JSON.stringify(right);
type OptionDefinitionWithRules = WidgetOptionDefinition & {
shouldHide?: (options: never, integrationKinds: IntegrationKind[]) => boolean;
options?: readonly { value: unknown; label: unknown }[];
};
type OptionFormatter = (value: unknown, definition: OptionDefinitionWithRules, t: Translator) => string | null;
const resolveSelectLabel = (value: unknown, definition: OptionDefinitionWithRules, t: Translator) => {
const match = definition.options?.find((option) => option.value === value);
const label = match?.label;
const fallbackLabel = () => String(value);
const labelResolvers: Record<string, () => string> = {
function: () => (label as (translator: Translator) => string)(t),
string: () => label as string,
missing: fallbackLabel,
};
const labelType = typeof label;
const resolveLabel: () => string = labelResolvers[labelType] ?? fallbackLabel;
return resolveLabel();
};
const optionTypeFormatters: Partial<Record<WidgetOptionType, OptionFormatter>> = {
switch: (value, _, t) => {
const switchLabels: Record<string, string> = {
true: t("common.action.show"),
false: t("common.action.hide"),
};
return switchLabels[String(Boolean(value))] ?? null;
},
text: (value) => {
const text = String(value).trim();
return text.length > 0 ? text : null;
},
number: (value) => String(value),
slider: (value) => String(value),
select: (value, definition, t) => resolveSelectLabel(value, definition, t),
multiSelect: (value) => {
const items = value as unknown[];
return items.length > 0 ? String(items.length) : null;
},
multiText: (value) => {
const items = (value as string[]).filter((entry) => entry.trim().length > 0);
return items.length > 0 ? items.join(", ") : null;
},
location: (value) => {
const location = value as { name?: string };
const name = location.name?.trim() ?? "";
return name.length > 0 ? name : null;
},
};
const formatOptionValue = (
type: WidgetOptionType,
value: unknown,
definition: OptionDefinitionWithRules,
t: Translator,
) => {
const formatter = optionTypeFormatters[type];
return formatter?.(value, definition, t) ?? null;
};
const getOptionLabel = (kind: WidgetKind, key: string, t: Translator) => {
const labelKey = `widget.${kind}.option.${key}.label`;
const translated = t(labelKey);
return translated === labelKey ? key : translated;
};
export const collectWidgetHoverMetadata = (
item: SectionItem,
settings: Pick<SettingsContextProps, "enableStatusByDefault" | "forceDisableStatus">,
integrationKinds: IntegrationKind[],
t: Translator,
): HoverMetadataItem[] => {
const { definition } = widgetImports[item.kind];
const optionDefinitions = definition.createOptions(settings) as Record<string, OptionDefinitionWithRules>;
const resolvedOptions = reduceWidgetOptionsWithDefaultValues(item.kind, settings, item.options);
return objectEntries(optionDefinitions)
.filter(([key, optionDefinition]) => {
const skip = skippedOptionTypes[optionDefinition.type];
const hidden = optionDefinition.shouldHide?.(resolvedOptions as never, integrationKinds);
const unchanged = isEqualValue(resolvedOptions[key], optionDefinition.defaultValue);
return !skip && !hidden && !unchanged;
})
.map(([key, optionDefinition]) => {
const formattedValue = formatOptionValue(optionDefinition.type, resolvedOptions[key], optionDefinition, t);
return {
label: getOptionLabel(item.kind, key, t),
value: formattedValue ?? "",
};
})
.filter((entry) => entry.value.length > 0)
.slice(0, maxMetadataItems);
};
interface DisplayNameSource {
matches: (item: SectionItem, options: Record<string, unknown>) => boolean;
resolve: (item: SectionItem, options: Record<string, unknown>) => string;
}
const displayNameSources: DisplayNameSource[] = [
{
matches: (item) => Boolean(item.advancedOptions.title?.trim()),
resolve: (item) => item.advancedOptions.title?.trim() ?? "",
},
{
matches: (item, options) =>
item.kind === "clock" &&
Boolean(options.customTitleToggle) &&
String(options.customTitle ?? "").trim().length > 0,
resolve: (_, options) => String(options.customTitle).trim(),
},
];
export const resolveWidgetDisplayName = (
item: SectionItem,
settings: Pick<SettingsContextProps, "enableStatusByDefault" | "forceDisableStatus">,
t: Translator,
) => {
const resolvedOptions = reduceWidgetOptionsWithDefaultValues(item.kind, settings, item.options);
const source = displayNameSources.find((entry) => entry.matches(item, resolvedOptions));
return source?.resolve(item, resolvedOptions) ?? t(`widget.${item.kind}.name`);
};
@@ -0,0 +1,116 @@
.wrapper {
position: absolute;
left: 10px;
right: 10px;
bottom: calc(100% - 10px);
margin-bottom: 1px;
z-index: 200;
pointer-events: auto;
opacity: 0;
transform: translateY(6px);
transition:
opacity 150ms ease,
transform 150ms ease;
}
:global(.grid-stack-item[data-type="item"]:not([data-kind="app"]):hover) .wrapper,
:global(.grid-stack-item[data-type="item"]:not([data-kind="app"]):focus-within) .wrapper {
opacity: 1;
transform: translateY(0);
}
.panel {
@mixin dark {
--background-color: rgb(from var(--mantine-color-dark-7) r g b / 0.96);
--border-color: rgb(from var(--mantine-color-dark-4) r g b / 0.9);
}
@mixin light {
--background-color: rgb(from var(--mantine-color-white) r g b / 0.96);
--border-color: rgb(from var(--mantine-color-gray-3) r g b / 0.9);
}
display: flex;
align-items: center;
gap: 8px;
min-height: 28px;
padding: 4px 8px;
border: 1px solid var(--border-color);
border-radius: var(--mantine-radius-md);
background-color: var(--background-color);
box-shadow: 0 4px 16px rgb(0 0 0 / 0.12);
backdrop-filter: blur(8px);
overflow: hidden;
}
.nameSection {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
flex-shrink: 0;
}
.widgetIcon {
flex-shrink: 0;
color: var(--mantine-color-dimmed);
}
.name {
font-size: 11px;
font-weight: 600;
line-height: 1.2;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.integrations {
flex-shrink: 0;
}
.integrationAvatar {
border: none;
}
.metadata {
display: flex;
align-items: center;
gap: 4px;
min-width: 0;
margin-left: auto;
}
.metadataItem {
@mixin dark {
--chip-background: rgb(from var(--mantine-color-dark-5) r g b / 0.9);
}
@mixin light {
--chip-background: rgb(from var(--mantine-color-gray-1) r g b / 0.95);
}
display: flex;
align-items: center;
gap: 4px;
max-width: 140px;
padding: 2px 6px;
border-radius: var(--mantine-radius-md);
background-color: var(--chip-background);
font-size: 10px;
line-height: 1.2;
white-space: nowrap;
overflow: hidden;
}
.metadataLabel {
color: var(--mantine-color-dimmed);
flex-shrink: 0;
}
.metadataValue {
overflow: hidden;
text-overflow: ellipsis;
}
@media (max-width: 768px) {
.wrapper {
display: none;
}
}
@@ -0,0 +1,86 @@
"use client";
import { Avatar, Group, Text, Tooltip } from "@mantine/core";
import type { RouterOutputs } from "@homarr/api";
import { useEditMode } from "@homarr/boards/edit-mode";
import { getIconUrl } from "@homarr/definitions";
import { useSettings } from "@homarr/settings";
import { useI18n } from "@homarr/translation/client";
import { widgetImports } from "@homarr/widgets";
import type { SectionItem } from "~/app/[locale]/boards/_types";
import type { Translator } from "./widget-hover-metadata";
import { collectWidgetHoverMetadata, resolveWidgetDisplayName } from "./widget-hover-metadata";
import classes from "./widget-hover-overlay.module.css";
interface WidgetHoverOverlayProps {
item: SectionItem;
integrations: RouterOutputs["integration"]["all"] | undefined;
}
export const WidgetHoverOverlay = ({ item, integrations }: WidgetHoverOverlayProps) => {
const [isEditMode] = useEditMode();
const settings = useSettings();
const t = useI18n();
const { definition } = widgetImports[item.kind];
const WidgetIcon = definition.icon;
const translate = ((key: string) => t(key as never)) as Translator;
const displayName = resolveWidgetDisplayName(item, settings, translate);
const connectedIntegrations = (integrations ?? []).filter((integration) =>
item.integrationIds.includes(integration.id),
);
const metadataItems = collectWidgetHoverMetadata(
item,
settings,
connectedIntegrations.map((integration) => integration.kind),
translate,
);
const hasIntegrations = connectedIntegrations.length > 0;
const hasMetadata = metadataItems.length > 0;
if (isEditMode) {
return null;
}
return (
<div className={classes.wrapper}>
<div className={classes.panel}>
<div className={classes.nameSection}>
<WidgetIcon size={14} stroke={1.75} className={classes.widgetIcon} />
<Text className={classes.name} title={displayName}>
{displayName}
</Text>
</div>
{hasIntegrations && (
<Avatar.Group spacing="xs" className={classes.integrations}>
{connectedIntegrations.map((integration) => (
<Tooltip key={integration.id} label={integration.name} withArrow position="top">
<Avatar
src={getIconUrl(integration.kind)}
size={20}
radius="sm"
className={classes.integrationAvatar}
/>
</Tooltip>
))}
</Avatar.Group>
)}
{hasMetadata && (
<Group className={classes.metadata} gap={4}>
{metadataItems.map((metadataItem) => (
<div key={`${metadataItem.label}-${metadataItem.value}`} className={classes.metadataItem}>
<span className={classes.metadataLabel}>{metadataItem.label}</span>
<span className={classes.metadataValue}>{metadataItem.value}</span>
</div>
))}
</Group>
)}
</div>
</div>
);
};
@@ -2,8 +2,12 @@ import { useMemo } from "react";
import type { GridItemHTMLElement } from "@homarr/gridstack";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import type { DynamicSectionItem, SectionItem } from "~/app/[locale]/boards/_types";
import { BoardItemContent } from "../items/item-content";
import { WidgetHoverOverlay } from "../items/widget-hover-overlay";
import { BoardDynamicSection } from "./dynamic-section";
import { GridStackItem } from "./gridstack/gridstack-item";
import { useSectionContext } from "./section-context";
@@ -11,6 +15,7 @@ import { useSectionItems } from "./use-section-items";
export const SectionContent = () => {
const { innerSections, items, refs } = useSectionContext();
const { data: integrations } = clientApi.integration.all.useQuery();
/**
* IMPORTANT: THE ORDER OF THE BELOW ITEMS HAS TO MATCH THE ORDER OF
@@ -18,7 +23,7 @@ export const SectionContent = () => {
* @see https://github.com/homarr-labs/homarr/pull/1770
*/
const sortedItems = useMemo(() => {
return [...items, ...innerSections].sort((itemA, itemB) => {
return [...items, ...innerSections].toSorted((itemA, itemB) => {
if (itemA.yOffset === itemB.yOffset) {
return itemA.xOffset - itemB.xOffset;
}
@@ -30,7 +35,7 @@ export const SectionContent = () => {
return (
<>
{sortedItems.map((item) => (
<Item key={item.id} item={item} innerRef={refs.items.current[item.id]} />
<Item key={item.id} item={item} innerRef={refs.items.current[item.id]} integrations={integrations} />
))}
</>
);
@@ -39,12 +44,12 @@ export const SectionContent = () => {
interface ItemProps {
item: DynamicSectionItem | SectionItem;
innerRef: React.RefObject<GridItemHTMLElement | null> | undefined;
integrations: RouterOutputs["integration"]["all"] | undefined;
}
const Item = ({ item, innerRef }: ItemProps) => {
const Item = ({ item, innerRef, integrations }: ItemProps) => {
const minWidth = useMinSize(item, "x");
const minHeight = useMinSize(item, "y");
return (
<GridStackItem
key={item.id}
@@ -59,6 +64,7 @@ const Item = ({ item, innerRef }: ItemProps) => {
minWidth={minWidth}
minHeight={minHeight}
>
{item.type === "item" && item.kind !== "app" && <WidgetHoverOverlay item={item} integrations={integrations} />}
{item.type === "item" ? <BoardItemContent item={item} /> : <BoardDynamicSection section={item} />}
</GridStackItem>
);
+4
View File
@@ -102,6 +102,10 @@
overflow-y: auto;
}
.grid-stack > .grid-stack-item[data-type="item"] {
overflow: visible;
}
.grid-stack.grid-stack-animate {
transition: none;
}
+4 -1
View File
@@ -9,10 +9,12 @@
"exports": {
".": "./src/index.ts",
"./client": "./src/client.ts",
"./query-cache": "./src/query-cache.ts",
"./server": "./src/server.ts",
"./websocket": "./src/websocket.ts",
"./shared": "./src/shared.ts",
"./open-api": "./src/open-api.ts"
"./open-api": "./src/open-api.ts",
"./mcp": "./src/mcp.ts"
},
"scripts": {
"clean": "rm -rf .turbo node_modules",
@@ -50,6 +52,7 @@
"react": "catalog:",
"react-dom": "catalog:",
"superjson": "catalog:",
"trpc-to-mcp": "catalog:",
"trpc-to-openapi": "catalog:",
"zod": "catalog:"
},
+36
View File
@@ -0,0 +1,36 @@
export { createTRPCContext } from "./trpc";
import { createTRPCRouter } from "./trpc";
import { appRouter as appRouterForApps } from "./router/app";
import { apiKeysRouter } from "./router/apiKeys";
import { boardRouter } from "./router/board";
import { dockerRouter } from "./router/docker/docker-router";
import { iconsRouter } from "./router/icons";
import { infoRouter } from "./router/info";
import { integrationRouter } from "./router/integration/integration-router";
import { inviteRouter } from "./router/invite";
import { calendarRouter } from "./router/widgets/calendar";
import { dnsHoleRouter } from "./router/widgets/dns-hole";
import { downloadsRouter } from "./router/widgets/downloads";
import { healthMonitoringRouter } from "./router/widgets/health-monitoring";
import { mediaRequestsRouter } from "./router/widgets/media-requests";
import { mediaServerRouter } from "./router/widgets/media-server";
import { smartHomeRouter } from "./router/widgets/smart-home";
export const mcpRouter = createTRPCRouter({
app: appRouterForApps,
apiKeys: apiKeysRouter,
board: boardRouter,
docker: dockerRouter,
icon: iconsRouter,
info: infoRouter,
integration: integrationRouter,
invite: inviteRouter,
calendar: calendarRouter,
dnsHole: dnsHoleRouter,
downloads: downloadsRouter,
healthMonitoring: healthMonitoringRouter,
mediaRequests: mediaRequestsRouter,
mediaServer: mediaServerRouter,
smartHome: smartHomeRouter,
});
+2
View File
@@ -3,6 +3,7 @@ import { generateOpenApiDocument } from "trpc-to-openapi";
import { API_KEY_HEADER_NAME } from "@homarr/auth/api-key";
import { appRouter } from "./router/app";
import { boardRouter } from "./router/board";
import { infoRouter } from "./router/info";
import { inviteRouter } from "./router/invite";
import { userRouter } from "./router/user";
@@ -10,6 +11,7 @@ import { createTRPCRouter } from "./trpc";
export const openApiRouter = createTRPCRouter({
appRouter,
boardRouter,
infoRouter,
inviteRouter,
userRouter,
+34
View File
@@ -0,0 +1,34 @@
import type { QueryKey } from "@tanstack/react-query";
export const queryCacheRetentionMs = 1000 * 60 * 60 * 24;
export const queryCacheDefaultStaleTimeMs = 1000 * 60 * 5;
export const queryCacheMaxValueBytes = 1024 * 1024;
export const queryCacheStoragePrefix = "homarr-widget-query";
let activeQueryCacheBoardId: string | null = null;
export const setActiveQueryCacheBoardId = (boardId: string | null) => {
activeQueryCacheBoardId = boardId;
};
export const getActiveQueryCacheBoardId = () => activeQueryCacheBoardId;
const persistableQueryPaths = new Set(["app.byId", "app.byIds", "docker.getContainers", "integration.byIds"]);
const excludedWidgetPaths = new Set(["widget.app.ping"]);
const getTrpcPathFromQueryKey = (queryKey: QueryKey) => {
const first = queryKey[0];
if (!Array.isArray(first)) return null;
const parts = first.filter((part): part is string => typeof part === "string");
return parts.length > 0 ? parts : null;
};
export const isPersistableWidgetQueryKey = (queryKey: QueryKey) => {
const path = getTrpcPathFromQueryKey(queryKey);
if (!path) return false;
if (path[0] === "widget") return !excludedWidgetPaths.has(path.join("."));
return persistableQueryPaths.has(path.join("."));
};
+1
View File
@@ -24,6 +24,7 @@ export const appRouter = createTRPCRouter({
cronJobs: lazy(() => import("./router/cron-jobs").then((mod) => mod.cronJobsRouter)),
apiKeys: lazy(() => import("./router/apiKeys").then((mod) => mod.apiKeysRouter)),
media: lazy(() => import("./router/medias/media-router").then((mod) => mod.mediaRouter)),
queryCache: lazy(() => import("./router/query-cache").then((mod) => mod.queryCacheRouter)),
updateChecker: lazy(() => import("./router/update-checker").then((mod) => mod.updateCheckerRouter)),
certificates: lazy(() => import("./router/certificates/certificate-router").then((mod) => mod.certificateRouter)),
bangs: lazy(() => import("./router/bangs/bangs-router").then((mod) => mod.bangsRouter)),
+49 -29
View File
@@ -1,7 +1,8 @@
import { z } from "zod/v4";
import { randomBytes } from "crypto";
import { hashPasswordAsync } from "@homarr/auth";
import { createId } from "@homarr/common";
import { generateSecureRandomToken } from "@homarr/common/server";
import { db, eq } from "@homarr/db";
import { apiKeys } from "@homarr/db/schema";
@@ -9,39 +10,58 @@ import { apiKeys } from "@homarr/db/schema";
import { createTRPCRouter, permissionRequiredProcedure } from "../trpc";
export const apiKeysRouter = createTRPCRouter({
getAll: permissionRequiredProcedure.requiresPermission("admin").query(() => {
return db.query.apiKeys.findMany({
columns: {
id: true,
apiKey: false,
},
with: {
user: {
columns: {
id: true,
name: true,
image: true,
email: true,
getAll: permissionRequiredProcedure
.requiresPermission("admin")
.meta({
mcp: { enabled: true, description: "List all API keys (admin only)" },
})
.query(() => {
return db.query.apiKeys.findMany({
columns: {
id: true,
apiKey: false,
},
with: {
user: {
columns: {
id: true,
name: true,
image: true,
email: true,
},
},
},
});
}),
create: permissionRequiredProcedure
.requiresPermission("admin")
.meta({
mcp: {
enabled: true,
description: "Create a new API key for the current user (admin only)",
},
});
}),
create: permissionRequiredProcedure.requiresPermission("admin").mutation(async ({ ctx }) => {
const randomToken = generateSecureRandomToken(64);
const hashedRandomToken = await hashPasswordAsync(randomToken);
const id = createId();
await db.insert(apiKeys).values({
id,
apiKey: hashedRandomToken,
userId: ctx.session.user.id,
});
return {
apiKey: `${id}.${randomToken}`,
};
}),
})
.mutation(async ({ ctx }) => {
const id = randomBytes(4).toString("hex");
const token = generateSecureRandomToken(24);
const hashedToken = await hashPasswordAsync(token);
await db.insert(apiKeys).values({
id,
apiKey: hashedToken,
userId: ctx.session.user.id,
});
return {
apiKey: `${id}.${token}`,
};
}),
delete: permissionRequiredProcedure
.requiresPermission("admin")
.meta({
mcp: {
enabled: true,
description: "Delete an API key by ID (admin only). REQUIRED: apiKeyId (string)",
},
})
.input(z.object({ apiKeyId: z.string() }))
.mutation(async ({ ctx, input }) => {
await ctx.db.delete(apiKeys).where(eq(apiKeys.id, input.apiKeyId)).limit(1);
+92 -9
View File
@@ -21,7 +21,19 @@ export const appRouter = createTRPCRouter({
getPaginated: protectedProcedure
.input(paginatedSchema)
.output(z.object({ items: z.array(selectAppSchema), totalCount: z.number() }))
.meta({ openapi: { method: "GET", path: "/api/apps/paginated", tags: ["apps"], protect: true } })
.meta({
openapi: {
method: "GET",
path: "/api/apps/paginated",
tags: ["apps"],
protect: true,
},
mcp: {
enabled: true,
description:
"List apps with pagination. OPTIONAL: search (string to filter by name), pageSize (number, default 10), page (number, default 1). All fields are optional — call with no arguments to get the first page",
},
})
.query(async ({ input, ctx }) => {
const whereQuery = input.search ? like(apps.name, `%${input.search.trim()}%`) : undefined;
const totalCount = await ctx.db.$count(apps, whereQuery);
@@ -41,16 +53,40 @@ export const appRouter = createTRPCRouter({
all: protectedProcedure
.input(z.void())
.output(z.array(selectAppSchema))
.meta({ openapi: { method: "GET", path: "/api/apps", tags: ["apps"], protect: true } })
.meta({
openapi: {
method: "GET",
path: "/api/apps",
tags: ["apps"],
protect: true,
},
mcp: { enabled: true, description: "List all apps" },
})
.query(({ ctx }) => {
return ctx.db.query.apps.findMany({
orderBy: asc(apps.name),
});
}),
search: protectedProcedure
.input(z.object({ query: z.string(), limit: z.number().min(1).max(100).default(10) }))
.input(
z.object({
query: z.string(),
limit: z.number().min(1).max(100).default(10),
}),
)
.output(z.array(selectAppSchema))
.meta({ openapi: { method: "GET", path: "/api/apps/search", tags: ["apps"], protect: true } })
.meta({
openapi: {
method: "GET",
path: "/api/apps/search",
tags: ["apps"],
protect: true,
},
mcp: {
enabled: true,
description: "Search apps by name. REQUIRED: query (search string). OPTIONAL: limit (number, default 10)",
},
})
.query(({ ctx, input }) => {
return ctx.db.query.apps.findMany({
where: like(apps.name, `%${input.query}%`),
@@ -62,7 +98,14 @@ export const appRouter = createTRPCRouter({
.input(z.void())
.output(
z.array(
selectAppSchema.pick({ id: true, name: true, iconUrl: true, href: true, pingUrl: true, description: true }),
selectAppSchema.pick({
id: true,
name: true,
iconUrl: true,
href: true,
pingUrl: true,
description: true,
}),
),
)
.meta({
@@ -89,7 +132,15 @@ export const appRouter = createTRPCRouter({
byId: publicProcedure
.input(byIdSchema)
.output(selectAppSchema)
.meta({ openapi: { method: "GET", path: "/api/apps/{id}", tags: ["apps"], protect: true } })
.meta({
openapi: {
method: "GET",
path: "/api/apps/{id}",
tags: ["apps"],
protect: true,
},
mcp: { enabled: true, description: "Get a single app by its ID. REQUIRED: id (app ID string)" },
})
.query(async ({ ctx, input }) => {
const repository = new AppRepository(ctx.db, ctx.session?.user ?? null);
const app = await repository.getByIdAsync(input.id);
@@ -111,7 +162,19 @@ export const appRouter = createTRPCRouter({
.requiresPermission("app-create")
.input(appManageSchema)
.output(z.object({ appId: z.string() }).and(selectAppSchema))
.meta({ openapi: { method: "POST", path: "/api/apps", tags: ["apps"], protect: true } })
.meta({
openapi: {
method: "POST",
path: "/api/apps",
tags: ["apps"],
protect: true,
},
mcp: {
enabled: true,
description:
"Create a new app (bookmark/shortcut to a service). REQUIRED: name (string), iconUrl (icon URL string), href (app URL, http/https or blank). OPTIONAL: description (string or null), pingUrl (URL to check reachability, or empty string)",
},
})
.mutation(async ({ ctx, input }) => {
const id = createId();
const insertValues = {
@@ -146,7 +209,19 @@ export const appRouter = createTRPCRouter({
.requiresPermission("app-modify-all")
.input(convertIntersectionToZodObject(appEditSchema))
.output(z.void())
.meta({ openapi: { method: "PATCH", path: "/api/apps/{id}", tags: ["apps"], protect: true } })
.meta({
openapi: {
method: "PATCH",
path: "/api/apps/{id}",
tags: ["apps"],
protect: true,
},
mcp: {
enabled: true,
description:
"Update an existing app. REQUIRED: id (app ID), name, iconUrl, href. OPTIONAL: description (string or null), pingUrl (URL or empty string)",
},
})
.mutation(async ({ ctx, input }) => {
const app = await ctx.db.query.apps.findFirst({
where: eq(apps.id, input.id),
@@ -173,7 +248,15 @@ export const appRouter = createTRPCRouter({
delete: permissionRequiredProcedure
.requiresPermission("app-full-all")
.output(z.void())
.meta({ openapi: { method: "DELETE", path: "/api/apps/{id}", tags: ["apps"], protect: true } })
.meta({
openapi: {
method: "DELETE",
path: "/api/apps/{id}",
tags: ["apps"],
protect: true,
},
mcp: { enabled: true, description: "Delete an app by ID. REQUIRED: id (app ID string)" },
})
.input(byIdSchema)
.mutation(async ({ ctx, input }) => {
await ctx.db.delete(apps).where(eq(apps.id, input.id));
+216 -69
View File
@@ -19,6 +19,7 @@ import {
integrationGroupPermissions,
integrationItems,
integrationUserPermissions,
integrations,
itemLayouts,
items,
layouts,
@@ -39,6 +40,7 @@ import { importOldmarrAsync } from "@homarr/old-import";
import { importJsonFileSchema } from "@homarr/old-import/shared";
import { oldmarrConfigSchema } from "@homarr/old-schema";
import {
addItemToBoardSchema,
boardByNameSchema,
boardChangeVisibilitySchema,
boardCreateSchema,
@@ -48,6 +50,7 @@ import {
boardSavePartialSettingsSchema,
boardSavePermissionsSchema,
boardSaveSchema,
boardSummarySchema,
} from "@homarr/validation/board";
import { byIdSchema } from "@homarr/validation/common";
import { zodUnionFromArray } from "@homarr/validation/enums";
@@ -114,78 +117,89 @@ export const boardRouter = createTRPCRouter({
where: boardWhere,
});
}),
getAllBoards: publicProcedure.query(async ({ ctx }) => {
const userId = ctx.session?.user.id;
const permissionsOfCurrentUserWhenPresent = await ctx.db.query.boardUserPermissions.findMany({
where: eq(boardUserPermissions.userId, userId ?? ""),
});
getAllBoards: publicProcedure
.input(z.void())
.output(z.array(boardSummarySchema))
.meta({
openapi: { method: "GET", path: "/api/boards", tags: ["boards"], protect: true },
mcp: {
enabled: true,
description:
"List all boards the current user can access. Returns id, name, logoImageUrl, isPublic, creator, isHome and isMobileHome flags",
},
})
.query(async ({ ctx }) => {
const userId = ctx.session?.user.id;
const permissionsOfCurrentUserWhenPresent = await ctx.db.query.boardUserPermissions.findMany({
where: eq(boardUserPermissions.userId, userId ?? ""),
});
const permissionsOfCurrentUserGroupsWhenPresent = await ctx.db.query.groupMembers.findMany({
where: eq(groupMembers.userId, userId ?? ""),
with: {
group: {
with: {
boardPermissions: {},
const permissionsOfCurrentUserGroupsWhenPresent = await ctx.db.query.groupMembers.findMany({
where: eq(groupMembers.userId, userId ?? ""),
with: {
group: {
with: {
boardPermissions: {},
},
},
},
},
});
const boardIds = permissionsOfCurrentUserWhenPresent
.map((permission) => permission.boardId)
.concat(
permissionsOfCurrentUserGroupsWhenPresent
.map((groupMember) => groupMember.group.boardPermissions.map((permission) => permission.boardId))
.flat(),
);
});
const boardIds = permissionsOfCurrentUserWhenPresent
.map((permission) => permission.boardId)
.concat(
permissionsOfCurrentUserGroupsWhenPresent
.map((groupMember) => groupMember.group.boardPermissions.map((permission) => permission.boardId))
.flat(),
);
const currentUserWhenPresent = await ctx.db.query.users.findFirst({
where: eq(users.id, userId ?? ""),
});
const currentUserWhenPresent = await ctx.db.query.users.findFirst({
where: eq(users.id, userId ?? ""),
});
const dbBoards = await ctx.db.query.boards.findMany({
columns: {
id: true,
name: true,
logoImageUrl: true,
isPublic: true,
},
with: {
creator: {
columns: {
id: true,
name: true,
image: true,
email: true,
const dbBoards = await ctx.db.query.boards.findMany({
columns: {
id: true,
name: true,
logoImageUrl: true,
isPublic: true,
},
with: {
creator: {
columns: {
id: true,
name: true,
image: true,
email: true,
},
},
userPermissions: {
where: eq(boardUserPermissions.userId, ctx.session?.user.id ?? ""),
},
groupPermissions: {
where:
permissionsOfCurrentUserGroupsWhenPresent.length >= 1
? inArray(
boardGroupPermissions.groupId,
permissionsOfCurrentUserGroupsWhenPresent.map((groupMember) => groupMember.groupId),
)
: undefined,
},
},
userPermissions: {
where: eq(boardUserPermissions.userId, ctx.session?.user.id ?? ""),
},
groupPermissions: {
where:
permissionsOfCurrentUserGroupsWhenPresent.length >= 1
? inArray(
boardGroupPermissions.groupId,
permissionsOfCurrentUserGroupsWhenPresent.map((groupMember) => groupMember.groupId),
)
: undefined,
},
},
// Allow viewing all boards if the user has the permission
where: ctx.session?.user.permissions.includes("board-view-all")
? undefined
: or(
eq(boards.isPublic, true),
eq(boards.creatorId, ctx.session?.user.id ?? ""),
boardIds.length > 0 ? inArray(boards.id, boardIds) : undefined,
),
});
return dbBoards.map((board) => ({
...board,
isHome: currentUserWhenPresent?.homeBoardId === board.id,
isMobileHome: currentUserWhenPresent?.mobileHomeBoardId === board.id,
}));
}),
// Allow viewing all boards if the user has the permission
where: ctx.session?.user.permissions.includes("board-view-all")
? undefined
: or(
eq(boards.isPublic, true),
eq(boards.creatorId, ctx.session?.user.id ?? ""),
boardIds.length > 0 ? inArray(boards.id, boardIds) : undefined,
),
});
return dbBoards.map((board) => ({
...board,
isHome: currentUserWhenPresent?.homeBoardId === board.id,
isMobileHome: currentUserWhenPresent?.mobileHomeBoardId === board.id,
}));
}),
search: publicProcedure
.input(z.object({ query: z.string(), limit: z.number().min(1).max(100).default(10) }))
.query(async ({ ctx, input }) => {
@@ -262,7 +276,16 @@ export const boardRouter = createTRPCRouter({
}),
createBoard: permissionRequiredProcedure
.requiresPermission("board-create")
.meta({
openapi: { method: "POST", path: "/api/boards", tags: ["boards"], protect: true },
mcp: {
enabled: true,
description:
"Create a new board with a name, column count (1-24), and isPublic flag. Returns { boardId }. Requires board-create permission",
},
})
.input(boardCreateSchema)
.output(z.object({ boardId: z.string() }))
.mutation(async ({ ctx, input }) => {
const boardId = createId();
@@ -547,11 +570,22 @@ export const boardRouter = createTRPCRouter({
.set({ isPublic: input.visibility === "public" })
.where(eq(boards.id, input.id));
}),
deleteBoard: protectedProcedure.input(z.object({ id: z.string() })).mutation(async ({ ctx, input }) => {
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "full");
deleteBoard: protectedProcedure
.meta({
openapi: { method: "DELETE", path: "/api/boards/{id}", tags: ["boards"], protect: true },
mcp: {
enabled: true,
description:
"Delete a board by its ID. Requires full permission on the board. Use board_getAllBoards to find the board ID",
},
})
.input(z.object({ id: z.string() }))
.output(z.void())
.mutation(async ({ ctx, input }) => {
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "full");
await ctx.db.delete(boards).where(eq(boards.id, input.id));
}),
await ctx.db.delete(boards).where(eq(boards.id, input.id));
}),
setHomeBoard: protectedProcedure.input(z.object({ id: z.string() })).mutation(async ({ ctx, input }) => {
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "view");
@@ -1327,6 +1361,119 @@ export const boardRouter = createTRPCRouter({
const oldmarr = oldmarrConfigSchema.parse(JSON.parse(content));
await importOldmarrAsync(ctx.db, oldmarr, input.configuration);
}),
addItem: protectedProcedure
.meta({
openapi: { method: "POST", path: "/api/boards/items", tags: ["boards"], protect: true },
mcp: {
enabled: true,
description:
"Add a widget/app item to a board. Automatically places it in the first empty section at the next free grid position. Provide boardId (from board_getAllBoards), kind (widget type like 'app', 'weather', etc.), optional options map, and optional integrationIds array. Returns { itemId }",
},
})
.input(addItemToBoardSchema)
.output(z.object({ itemId: z.string() }))
.mutation(async ({ ctx, input }) => {
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.boardId), "modify");
if (input.integrationIds.length > 0) {
const existing = await ctx.db.query.integrations.findMany({
columns: { id: true },
where: inArray(integrations.id, input.integrationIds),
});
const validIds = new Set(existing.map((row) => row.id));
const invalid = input.integrationIds.filter((id) => !validIds.has(id));
if (invalid.length > 0) {
throw new TRPCError({ code: "BAD_REQUEST", message: `Invalid integration IDs: ${invalid.join(", ")}` });
}
}
const board = await ctx.db.query.boards.findFirst({
where: eq(boards.id, input.boardId),
with: {
sections: true,
layouts: true,
items: { with: { layouts: true } },
},
});
if (!board) {
throw new TRPCError({ code: "NOT_FOUND", message: "Board not found" });
}
const emptySection = board.sections
.filter((s) => s.kind === "empty")
.toSorted((a, b) => (a.yOffset ?? 0) - (b.yOffset ?? 0))[0];
if (!emptySection) {
throw new TRPCError({ code: "BAD_REQUEST", message: "Board has no empty section to place items in" });
}
const itemId = createId();
await ctx.db.insert(items).values({
id: itemId,
boardId: input.boardId,
kind: input.kind,
options: superjson.stringify(input.options),
advancedOptions: emptySuperJSON,
});
const layoutRows: (typeof itemLayouts.$inferInsert)[] = [];
for (const layout of board.layouts) {
const existingInSection = board.items
.flatMap((item) => item.layouts)
.filter((il) => il.sectionId === emptySection.id && il.layoutId === layout.id);
const occupied: boolean[][] = [];
for (const il of existingInSection) {
for (let y = il.yOffset; y < il.yOffset + il.height; y++) {
while (occupied.length <= y) occupied.push(Array.from<boolean>({ length: layout.columnCount }).fill(false));
for (let x = il.xOffset; x < il.xOffset + il.width; x++) {
occupied[y]![x] = true;
}
}
}
let placed = false;
for (let y = 0; y < 9999 && !placed; y++) {
if (!occupied[y]) occupied.push(Array.from<boolean>({ length: layout.columnCount }).fill(false));
for (let x = 0; x < layout.columnCount && !placed; x++) {
if (!occupied[y]![x]) {
layoutRows.push({
itemId,
sectionId: emptySection.id,
layoutId: layout.id,
xOffset: x,
yOffset: y,
width: 1,
height: 1,
});
placed = true;
}
}
}
if (!placed) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Board section is full, no free grid position available",
});
}
}
if (layoutRows.length > 0) {
await ctx.db.insert(itemLayouts).values(layoutRows);
}
if (input.integrationIds.length > 0) {
await ctx.db
.insert(integrationItems)
.values(input.integrationIds.map((integrationId) => ({ itemId, integrationId })));
}
return { itemId };
}),
});
/**
@@ -16,10 +16,18 @@ import { createTRPCRouter, permissionRequiredProcedure } from "../../trpc";
export const dockerRouter = createTRPCRouter({
getContainers: permissionRequiredProcedure
.requiresPermission("admin")
.meta({
mcp: {
enabled: true,
description: "List all Docker containers with their state, image, CPU/memory usage, and ports",
},
})
.concat(dockerMiddleware())
.query(async () => {
const innerHandler = dockerContainersRequestHandler.handler({});
const result = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
const result = await innerHandler.getCachedOrUpdatedDataAsync({
forceUpdate: false,
});
const { data, timestamp } = result;
@@ -50,6 +58,12 @@ export const dockerRouter = createTRPCRouter({
}),
startAll: permissionRequiredProcedure
.requiresPermission("admin")
.meta({
mcp: {
enabled: true,
description: "Start Docker containers. REQUIRED: ids (array of container ID strings from docker_getContainers)",
},
})
.concat(dockerMiddleware())
.input(z.object({ ids: z.array(z.string()) }))
.mutation(async ({ input }) => {
@@ -65,6 +79,12 @@ export const dockerRouter = createTRPCRouter({
}),
stopAll: permissionRequiredProcedure
.requiresPermission("admin")
.meta({
mcp: {
enabled: true,
description: "Stop Docker containers. REQUIRED: ids (array of container ID strings from docker_getContainers)",
},
})
.concat(dockerMiddleware())
.input(z.object({ ids: z.array(z.string()) }))
.mutation(async ({ input }) => {
@@ -80,6 +100,13 @@ export const dockerRouter = createTRPCRouter({
}),
restartAll: permissionRequiredProcedure
.requiresPermission("admin")
.meta({
mcp: {
enabled: true,
description:
"Restart Docker containers. REQUIRED: ids (array of container ID strings from docker_getContainers)",
},
})
.concat(dockerMiddleware())
.input(z.object({ ids: z.array(z.string()) }))
.mutation(async ({ input }) => {
@@ -95,6 +122,13 @@ export const dockerRouter = createTRPCRouter({
}),
removeAll: permissionRequiredProcedure
.requiresPermission("admin")
.meta({
mcp: {
enabled: true,
description:
"Remove/delete Docker containers. REQUIRED: ids (array of container ID strings from docker_getContainers)",
},
})
.concat(dockerMiddleware())
.input(z.object({ ids: z.array(z.string()) }))
.mutation(async ({ input }) => {
@@ -110,6 +144,13 @@ export const dockerRouter = createTRPCRouter({
}),
logs: permissionRequiredProcedure
.requiresPermission("admin")
.meta({
mcp: {
enabled: true,
description:
"Fetch logs from a Docker container. REQUIRED: id (container ID from docker_getContainers). OPTIONAL: tail (number 1-1000, default 200)",
},
})
.concat(dockerMiddleware())
.input(
z.object({
+29 -20
View File
@@ -5,26 +5,35 @@ import { iconsFindSchema } from "@homarr/validation/icons";
import { createTRPCRouter, publicProcedure } from "../trpc";
export const iconsRouter = createTRPCRouter({
findIcons: publicProcedure.input(iconsFindSchema).query(async ({ ctx, input }) => {
return {
icons: await ctx.db.query.iconRepositories.findMany({
with: {
icons: {
columns: {
id: true,
name: true,
url: true,
findIcons: publicProcedure
.meta({
mcp: {
enabled: true,
description:
"Search for icons by name across all icon repositories. OPTIONAL: searchText (string to filter), limitPerGroup (number 1-500, default 12). Call with no arguments to browse all icons",
},
})
.input(iconsFindSchema)
.query(async ({ ctx, input }) => {
return {
icons: await ctx.db.query.iconRepositories.findMany({
with: {
icons: {
columns: {
id: true,
name: true,
url: true,
},
where:
(input.searchText?.length ?? 0) > 0
? // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
and(...input.searchText!.split(" ").map((keyword) => like(icons.name, `%${keyword}%`)))
: undefined,
limit: input.limitPerGroup,
},
where:
(input.searchText?.length ?? 0) > 0
? // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
and(...input.searchText!.split(" ").map((keyword) => like(icons.name, `%${keyword}%`)))
: undefined,
limit: input.limitPerGroup,
},
},
}),
countIcons: await ctx.db.$count(icons),
};
}),
}),
countIcons: await ctx.db.$count(icons),
};
}),
});
+7 -1
View File
@@ -7,7 +7,13 @@ export const infoRouter = createTRPCRouter({
getInfo: protectedProcedure
.input(z.void())
.output(z.object({ version: z.string() }))
.meta({ openapi: { method: "GET", path: "/api/info", tags: ["info"] } })
.meta({
openapi: { method: "GET", path: "/api/info", tags: ["info"] },
mcp: {
enabled: true,
description: "Get Homarr server version information",
},
})
.query(() => {
return {
version: packageJson.version,
@@ -45,48 +45,72 @@ const logger = createLogger({ module: "integrationRouter" });
const mediaRequestSearchKinds = getIntegrationKindsByCategory("mediaSearch");
export const integrationRouter = createTRPCRouter({
all: protectedProcedure.query(async ({ ctx }) => {
const groupsOfCurrentUser = await ctx.db.query.groupMembers.findMany({
where: eq(groupMembers.userId, ctx.session.user.id),
});
const integrations = await ctx.db.query.integrations.findMany({
with: {
userPermissions: {
where: eq(integrationUserPermissions.userId, ctx.session.user.id),
},
groupPermissions: {
where: inArray(
integrationGroupPermissions.groupId,
groupsOfCurrentUser.map((group) => group.groupId),
),
},
getKinds: publicProcedure
.meta({
mcp: {
enabled: true,
description:
"List all supported integration kinds (e.g. sonarr, radarr, overseerr, pihole, homeAssistant) with the secret fields each kind requires. Use this before creating an integration to know which 'kind' values are valid and what secrets to provide.",
},
});
return integrations
.map((integration) => {
const permissions = integration.userPermissions
.map(({ permission }) => permission)
.concat(integration.groupPermissions.map(({ permission }) => permission));
})
.query(() => {
return objectEntries(integrationDefs).map(([kind, def]) => ({
kind,
name: def.name,
category: def.category,
requiredSecrets: def.secretKinds,
}));
}),
all: protectedProcedure
.meta({
mcp: {
enabled: true,
description:
"List all configured integrations (connections to services like Sonarr, Radarr, Plex, etc.). Returns each integration's id, name, kind, url, and permissions. Use the 'id' field as 'integrationId' in other tools. Check permissions.hasUseAccess before reading data and permissions.hasInteractAccess before performing actions — false means the API key owner lacks that permission level for this integration, not an error",
},
})
.query(async ({ ctx }) => {
const groupsOfCurrentUser = await ctx.db.query.groupMembers.findMany({
where: eq(groupMembers.userId, ctx.session.user.id),
});
return {
id: integration.id,
name: integration.name,
kind: integration.kind,
url: integration.url,
permissions: {
hasUseAccess:
permissions.includes("use") || permissions.includes("interact") || permissions.includes("full"),
hasInteractAccess: permissions.includes("interact") || permissions.includes("full"),
hasFullAccess: permissions.includes("full"),
const integrations = await ctx.db.query.integrations.findMany({
with: {
userPermissions: {
where: eq(integrationUserPermissions.userId, ctx.session.user.id),
},
};
})
.toSorted(
(integrationA, integrationB) =>
integrationKinds.indexOf(integrationA.kind) - integrationKinds.indexOf(integrationB.kind),
);
}),
groupPermissions: {
where: inArray(
integrationGroupPermissions.groupId,
groupsOfCurrentUser.map((group) => group.groupId),
),
},
},
});
return integrations
.map((integration) => {
const permissions = integration.userPermissions
.map(({ permission }) => permission)
.concat(integration.groupPermissions.map(({ permission }) => permission));
return {
id: integration.id,
name: integration.name,
kind: integration.kind,
url: integration.url,
permissions: {
hasUseAccess:
permissions.includes("use") || permissions.includes("interact") || permissions.includes("full"),
hasInteractAccess: permissions.includes("interact") || permissions.includes("full"),
hasFullAccess: permissions.includes("full"),
},
};
})
.toSorted(
(integrationA, integrationB) =>
integrationKinds.indexOf(integrationA.kind) - integrationKinds.indexOf(integrationB.kind),
);
}),
allThatSupportSearch: protectedProcedure.query(async ({ ctx }) => {
const groupsOfCurrentUser = await ctx.db.query.groupMembers.findMany({
where: eq(groupMembers.userId, ctx.session.user.id),
@@ -187,7 +211,19 @@ export const integrationRouter = createTRPCRouter({
);
}),
search: protectedProcedure
.input(z.object({ query: z.string(), limit: z.number().min(1).max(100).default(10) }))
.meta({
mcp: {
enabled: true,
description:
"Search integrations by name. REQUIRED: query (search string). OPTIONAL: limit (number, default 10). Returns matching integrations with id (use as integrationId), name, kind, url, and permissions",
},
})
.input(
z.object({
query: z.string(),
limit: z.number().min(1).max(100).default(10),
}),
)
.query(async ({ ctx, input }) => {
return await ctx.db.query.integrations.findMany({
where: like(integrations.name, `%${input.query}%`),
@@ -205,52 +241,68 @@ export const integrationRouter = createTRPCRouter({
},
});
}),
byId: protectedProcedure.input(byIdSchema).query(async ({ ctx, input }) => {
await throwIfActionForbiddenAsync(ctx, eq(integrations.id, input.id), "full");
const integration = await ctx.db.query.integrations.findFirst({
where: eq(integrations.id, input.id),
with: {
secrets: {
columns: {
kind: true,
value: true,
updatedAt: true,
},
},
app: {
columns: {
id: true,
name: true,
iconUrl: true,
href: true,
},
},
byId: protectedProcedure
.meta({
mcp: {
enabled: true,
description:
"Get integration details by ID, including secrets metadata and linked app. REQUIRED: id (integration ID string)",
},
});
if (!integration) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Integration not found",
})
.input(byIdSchema)
.query(async ({ ctx, input }) => {
await throwIfActionForbiddenAsync(ctx, eq(integrations.id, input.id), "full");
const integration = await ctx.db.query.integrations.findFirst({
where: eq(integrations.id, input.id),
with: {
secrets: {
columns: {
kind: true,
value: true,
updatedAt: true,
},
},
app: {
columns: {
id: true,
name: true,
iconUrl: true,
href: true,
},
},
},
});
}
return {
id: integration.id,
name: integration.name,
kind: integration.kind,
url: integration.url,
secrets: integration.secrets.map((secret) => ({
kind: secret.kind,
// Only return the value if the secret is public, so for example the username
value: integrationSecretKindObject[secret.kind].isPublic ? decryptSecret(secret.value) : null,
updatedAt: secret.updatedAt,
})),
app: integration.app,
};
}),
if (!integration) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Integration not found",
});
}
return {
id: integration.id,
name: integration.name,
kind: integration.kind,
url: integration.url,
secrets: integration.secrets.map((secret) => ({
kind: secret.kind,
// Only return the value if the secret is public, so for example the username
value: integrationSecretKindObject[secret.kind].isPublic ? decryptSecret(secret.value) : null,
updatedAt: secret.updatedAt,
})),
app: integration.app,
};
}),
create: permissionRequiredProcedure
.requiresPermission("integration-create")
.meta({
mcp: {
enabled: true,
description:
"Create a new integration (connection to an external service). REQUIRED fields: name, url (http/https), kind, secrets, attemptSearchEngineCreation. The 'secrets' field is REQUIRED and must be a non-empty array — call integration_getKinds first to see which secret kinds each integration type needs. Example for Radarr: secrets=[{kind:'apiKey', value:'your-radarr-api-key'}]. Example for Proxmox: secrets=[{kind:'tokenId', value:'...'}, {kind:'personalAccessToken', value:'...'}, {kind:'realm', value:'pam'}]. The connection is tested before saving — if secrets are wrong, an error is returned. Set attemptSearchEngineCreation to false unless explicitly requested. The 'app' field is optional — pass {id:'...'} to link to an existing app, or omit it.",
},
})
.input(integrationCreateSchema)
.mutation(async ({ ctx, input }) => {
logger.info("Creating integration", {
@@ -315,8 +367,6 @@ export const integrationRouter = createTRPCRouter({
kind: input.kind,
url: input.url,
});
// Media request search is a first-class Spotlight action. Do not create a search engine for it.
}),
update: protectedProcedure.input(integrationUpdateSchema).mutation(async ({ ctx, input }) => {
await throwIfActionForbiddenAsync(ctx, eq(integrations.id, input.id), "full");
@@ -420,22 +470,27 @@ export const integrationRouter = createTRPCRouter({
url: input.url,
});
}),
delete: protectedProcedure.input(byIdSchema).mutation(async ({ ctx, input }) => {
await throwIfActionForbiddenAsync(ctx, eq(integrations.id, input.id), "full");
delete: protectedProcedure
.meta({
mcp: { enabled: true, description: "Delete an integration by ID. REQUIRED: id (integration ID string)" },
})
.input(byIdSchema)
.mutation(async ({ ctx, input }) => {
await throwIfActionForbiddenAsync(ctx, eq(integrations.id, input.id), "full");
const integration = await ctx.db.query.integrations.findFirst({
where: eq(integrations.id, input.id),
});
if (!integration) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Integration not found",
const integration = await ctx.db.query.integrations.findFirst({
where: eq(integrations.id, input.id),
});
}
await ctx.db.delete(integrations).where(eq(integrations.id, input.id));
}),
if (!integration) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Integration not found",
});
}
await ctx.db.delete(integrations).where(eq(integrations.id, input.id));
}),
getIntegrationPermissions: protectedProcedure.input(byIdSchema).query(async ({ input, ctx }) => {
await throwIfActionForbiddenAsync(ctx, eq(integrations.id, input.id), "full");
@@ -601,30 +656,51 @@ export const integrationRouter = createTRPCRouter({
});
}),
searchInIntegration: protectedProcedure
.meta({
mcp: {
enabled: true,
description:
"Search within a specific integration (e.g. Overseerr, Jellyseerr). REQUIRED: integrationId (from integration_all), query (search string)",
},
})
.concat(createOneIntegrationMiddleware("query", ...getIntegrationKindsByCategory("search")))
.input(z.object({ integrationId: z.string(), query: z.string() }))
.query(async ({ ctx, input }) => {
const integrationInstance = await createIntegrationAsync(ctx.integration);
return await integrationInstance.searchAsync(encodeURI(input.query));
}),
mediaRequestSearchTargets: protectedProcedure.query(async ({ ctx }) => {
const integrationsWithAccess = await getAccessibleMediaRequestSearchIntegrationsAsync(ctx);
mediaRequestSearchTargets: protectedProcedure
.meta({
mcp: {
enabled: true,
description: "List integrations that support media request search (Overseerr, Jellyseerr, Seerr)",
},
})
.query(async ({ ctx }) => {
const integrationsWithAccess = await getAccessibleMediaRequestSearchIntegrationsAsync(ctx);
return integrationsWithAccess.map((integration) => {
const permissions = constructIntegrationPermissions(integration, ctx.session);
return integrationsWithAccess.map((integration) => {
const permissions = constructIntegrationPermissions(integration, ctx.session);
return {
id: integration.id,
name: integration.name,
kind: integration.kind,
url: integration.url,
permissions: {
hasInteractAccess: permissions.hasInteractAccess,
},
};
});
}),
return {
id: integration.id,
name: integration.name,
kind: integration.kind,
url: integration.url,
permissions: {
hasInteractAccess: permissions.hasInteractAccess,
},
};
});
}),
searchMediaRequests: protectedProcedure
.meta({
mcp: {
enabled: true,
description:
"Search for movies and TV shows across Overseerr/Jellyseerr/Seerr. REQUIRED: query (search string). OPTIONAL: integrationIds (array of integration IDs — omit to search all accessible Overseerr/Jellyseerr/Seerr integrations). Returns results with integrationId, mediaId, and mediaType for use with getMediaRequestOptions and requestMedia",
},
})
.input(
z.object({
query: z.string(),
@@ -672,6 +748,13 @@ export const integrationRouter = createTRPCRouter({
return results.flat();
}),
getMediaRequestOptions: protectedProcedure
.meta({
mcp: {
enabled: true,
description:
"Get available options (seasons/episodes for TV) before requesting media. REQUIRED: integrationId (Overseerr/Jellyseerr ID), mediaId (number from searchMediaRequests), mediaType ('tv' or 'movie'). Required step for TV shows — for movies you can skip this and go straight to requestMedia",
},
})
.concat(createOneIntegrationMiddleware("query", "jellyseerr", "overseerr", "seerr"))
.input(mediaRequestOptionsSchema)
.query(async ({ ctx, input }) => {
@@ -679,6 +762,13 @@ export const integrationRouter = createTRPCRouter({
return await integration.getSeriesInformationAsync(input.mediaType, input.mediaId);
}),
requestMedia: protectedProcedure
.meta({
mcp: {
enabled: true,
description:
"Submit a media request to Overseerr/Jellyseerr/Seerr. REQUIRED: integrationId (Overseerr/Jellyseerr ID), mediaId (number), mediaType ('tv' or 'movie'). OPTIONAL: seasons (array of season numbers — for TV only, omit to request all). Workflow: searchMediaRequests → (getMediaRequestOptions for TV) → requestMedia",
},
})
.concat(createOneIntegrationMiddleware("interact", "jellyseerr", "overseerr", "seerr"))
.input(mediaRequestRequestSchema)
.mutation(async ({ ctx, input }) => {
+34 -4
View File
@@ -20,11 +20,21 @@ export const inviteRouter = createTRPCRouter({
id: true,
expirationDate: true,
})
.extend({ creator: z.object({ name: z.string().nullable(), id: z.string() }) }),
.extend({
creator: z.object({ name: z.string().nullable(), id: z.string() }),
}),
),
)
.input(z.undefined())
.meta({ openapi: { method: "GET", path: "/api/invites", tags: ["invites"], protect: true } })
.meta({
openapi: {
method: "GET",
path: "/api/invites",
tags: ["invites"],
protect: true,
},
mcp: { enabled: true, description: "List all user invites" },
})
.query(async ({ ctx }) => {
throwIfCredentialsDisabled();
return await ctx.db.query.invites.findMany({
@@ -50,7 +60,19 @@ export const inviteRouter = createTRPCRouter({
}),
)
.output(z.object({ id: z.string(), token: z.string() }))
.meta({ openapi: { method: "POST", path: "/api/invites", tags: ["invites"], protect: true } })
.meta({
openapi: {
method: "POST",
path: "/api/invites",
tags: ["invites"],
protect: true,
},
mcp: {
enabled: true,
description:
"Create a new user invite link. REQUIRED: expirationDate (ISO date string for when the invite expires). Returns the invite ID and token",
},
})
.mutation(async ({ ctx, input }) => {
throwIfCredentialsDisabled();
const id = createId();
@@ -76,7 +98,15 @@ export const inviteRouter = createTRPCRouter({
}),
)
.output(z.undefined())
.meta({ openapi: { method: "DELETE", path: "/api/invites/{id}", tags: ["invites"], protect: true } })
.meta({
openapi: {
method: "DELETE",
path: "/api/invites/{id}",
tags: ["invites"],
protect: true,
},
mcp: { enabled: true, description: "Delete an invite by ID. REQUIRED: id (invite ID string)" },
})
.mutation(async ({ ctx, input }) => {
throwIfCredentialsDisabled();
const dbInvite = await ctx.db.query.invites.findFirst({
+54
View File
@@ -0,0 +1,54 @@
import { z } from "zod/v4";
import { eq } from "@homarr/db";
import { boards } from "@homarr/db/schema";
import { createQueryCacheChannel } from "@homarr/redis";
import { queryCacheMaxValueBytes, queryCacheRetentionMs } from "../query-cache";
import { createTRPCRouter, publicProcedure } from "../trpc";
import { throwIfActionForbiddenAsync } from "./board/board-access";
const queryCacheKeyInput = z.object({
boardId: z.string().min(1),
key: z.string().min(1).max(4096),
});
const queryCacheSetInput = queryCacheKeyInput.extend({
value: z.string().max(queryCacheMaxValueBytes * 2),
});
const createChannel = (input: z.infer<typeof queryCacheKeyInput>, userId: string) =>
createQueryCacheChannel({
userId,
boardId: input.boardId,
key: input.key,
ttlMs: queryCacheRetentionMs,
maxValueBytes: queryCacheMaxValueBytes,
});
const validateBoardAccessAsync = async (ctx: Parameters<typeof throwIfActionForbiddenAsync>[0], boardId: string) => {
await throwIfActionForbiddenAsync(ctx, eq(boards.id, boardId), "view");
};
export const queryCacheRouter = createTRPCRouter({
getItem: publicProcedure.input(queryCacheKeyInput).query(async ({ ctx, input }) => {
await validateBoardAccessAsync(ctx, input.boardId);
const channel = createChannel(input, ctx.session?.user.id ?? "anonymous");
return await channel.getAsync();
}),
setItem: publicProcedure.input(queryCacheSetInput).mutation(async ({ ctx, input }) => {
await validateBoardAccessAsync(ctx, input.boardId);
const channel = createChannel(input, ctx.session?.user.id ?? "anonymous");
return {
stored: await channel.setAsync(input.value),
};
}),
removeItem: publicProcedure.input(queryCacheKeyInput).mutation(async ({ ctx, input }) => {
await validateBoardAccessAsync(ctx, input.boardId);
const channel = createChannel(input, ctx.session?.user.id ?? "anonymous");
await channel.removeAsync();
}),
});
+143 -16
View File
@@ -144,7 +144,19 @@ export const userRouter = createTRPCRouter({
}),
create: permissionRequiredProcedure
.requiresPermission("admin")
.meta({ openapi: { method: "POST", path: "/api/users", tags: ["users"], protect: true } })
.meta({
openapi: {
method: "POST",
path: "/api/users",
tags: ["users"],
protect: true,
},
mcp: {
enabled: true,
description:
"Create a new user account (admin only). REQUIRED: username (string), password (string), confirmPassword (must match password). OPTIONAL: email (string or null)",
},
})
.input(convertIntersectionToZodObject(userCreateSchema))
.output(z.void())
.mutation(async ({ ctx, input }) => {
@@ -159,7 +171,14 @@ export const userRouter = createTRPCRouter({
}),
setProfileImage: protectedProcedure
.output(z.void())
.meta({ openapi: { method: "PUT", path: "/api/users/profileImage", tags: ["users"], protect: true } })
.meta({
openapi: {
method: "PUT",
path: "/api/users/profileImage",
tags: ["users"],
protect: true,
},
})
.input(
z.object({
userId: z.string(),
@@ -205,8 +224,26 @@ export const userRouter = createTRPCRouter({
getAll: permissionRequiredProcedure
.requiresPermission("admin")
.input(z.void())
.output(z.array(selectUserSchema.pick({ id: true, name: true, email: true, emailVerified: true, image: true })))
.meta({ openapi: { method: "GET", path: "/api/users", tags: ["users"], protect: true } })
.output(
z.array(
selectUserSchema.pick({
id: true,
name: true,
email: true,
emailVerified: true,
image: true,
}),
),
)
.meta({
openapi: {
method: "GET",
path: "/api/users",
tags: ["users"],
protect: true,
},
mcp: { enabled: true, description: "List all users (admin only)" },
})
.query(({ ctx }) => {
return ctx.db.query.users.findMany({
columns: {
@@ -221,8 +258,24 @@ export const userRouter = createTRPCRouter({
// Is protected because also used in board access / integration access forms
selectable: protectedProcedure
.input(z.object({ excludeExternalProviders: z.boolean().default(false) }).optional())
.output(z.array(selectUserSchema.pick({ id: true, name: true, image: true, email: true })))
.meta({ openapi: { method: "GET", path: "/api/users/selectable", tags: ["users"], protect: true } })
.output(
z.array(
selectUserSchema.pick({
id: true,
name: true,
image: true,
email: true,
}),
),
)
.meta({
openapi: {
method: "GET",
path: "/api/users/selectable",
tags: ["users"],
protect: true,
},
})
.query(({ ctx, input }) => {
return ctx.db.query.users.findMany({
columns: {
@@ -242,8 +295,24 @@ export const userRouter = createTRPCRouter({
limit: z.number().min(1).max(100).default(10),
}),
)
.output(z.array(selectUserSchema.pick({ id: true, name: true, image: true, email: true })))
.meta({ openapi: { method: "POST", path: "/api/users/search", tags: ["users"], protect: true } })
.output(
z.array(
selectUserSchema.pick({
id: true,
name: true,
image: true,
email: true,
}),
),
)
.meta({
openapi: {
method: "POST",
path: "/api/users/search",
tags: ["users"],
protect: true,
},
})
.query(async ({ input, ctx }) => {
const dbUsers = await ctx.db.query.users.findMany({
columns: {
@@ -283,7 +352,15 @@ export const userRouter = createTRPCRouter({
completedBoardTour: true,
}),
)
.meta({ openapi: { method: "GET", path: "/api/users/{userId}", tags: ["users"], protect: true } })
.meta({
openapi: {
method: "GET",
path: "/api/users/{userId}",
tags: ["users"],
protect: true,
},
mcp: { enabled: true, description: "Get user details by user ID. REQUIRED: userId (string)" },
})
.query(async ({ input, ctx }) => {
// Only admins can view other users details
if (ctx.session.user.id !== input.userId && !ctx.session.user.permissions.includes("admin")) {
@@ -325,7 +402,14 @@ export const userRouter = createTRPCRouter({
editProfile: protectedProcedure
.input(userEditProfileSchema)
.output(z.void())
.meta({ openapi: { method: "PUT", path: "/api/users/profile", tags: ["users"], protect: true } })
.meta({
openapi: {
method: "PUT",
path: "/api/users/profile",
tags: ["users"],
protect: true,
},
})
.mutation(async ({ input, ctx }) => {
// Only admins can view other users details
if (ctx.session.user.id !== input.id && !ctx.session.user.permissions.includes("admin")) {
@@ -369,7 +453,15 @@ export const userRouter = createTRPCRouter({
delete: protectedProcedure
.input(z.object({ userId: z.string() }))
.output(z.void())
.meta({ openapi: { method: "DELETE", path: "/api/users/{userId}", tags: ["users"], protect: true } })
.meta({
openapi: {
method: "DELETE",
path: "/api/users/{userId}",
tags: ["users"],
protect: true,
},
mcp: { enabled: true, description: "Delete a user by ID. REQUIRED: userId (string)" },
})
.mutation(async ({ input, ctx }) => {
// Only admins and user itself can delete a user
if (ctx.session.user.id !== input.userId && !ctx.session.user.permissions.includes("admin")) {
@@ -384,7 +476,14 @@ export const userRouter = createTRPCRouter({
changePassword: protectedProcedure
.input(convertIntersectionToZodObject(userChangePasswordApiSchema))
.output(z.void())
.meta({ openapi: { method: "PATCH", path: "/api/users/{userId}/changePassword", tags: ["users"], protect: true } })
.meta({
openapi: {
method: "PATCH",
path: "/api/users/{userId}/changePassword",
tags: ["users"],
protect: true,
},
})
.mutation(async ({ ctx, input }) => {
const user = ctx.session.user;
// Only admins can change other users' passwords
@@ -449,7 +548,14 @@ export const userRouter = createTRPCRouter({
changeHomeBoards: protectedProcedure
.input(convertIntersectionToZodObject(userChangeHomeBoardsSchema.and(z.object({ userId: z.string() }))))
.output(z.void())
.meta({ openapi: { method: "PATCH", path: "/api/users/changeHome", tags: ["users"], protect: true } })
.meta({
openapi: {
method: "PATCH",
path: "/api/users/changeHome",
tags: ["users"],
protect: true,
},
})
.mutation(async ({ input, ctx }) => {
const user = ctx.session.user;
// Only admins can change other users passwords
@@ -515,14 +621,28 @@ export const userRouter = createTRPCRouter({
changeSearchPreferences: protectedProcedure
.input(convertIntersectionToZodObject(changeSearchPreferencesInputSchema))
.output(z.void())
.meta({ openapi: { method: "PATCH", path: "/api/users/search-preferences", tags: ["users"], protect: true } })
.meta({
openapi: {
method: "PATCH",
path: "/api/users/search-preferences",
tags: ["users"],
protect: true,
},
})
.mutation(async ({ input, ctx }) => {
await changeSearchPreferencesAsync(ctx.db, ctx.session, input);
}),
changeColorScheme: protectedProcedure
.input(userChangeColorSchemeSchema)
.output(z.void())
.meta({ openapi: { method: "PATCH", path: "/api/users/changeScheme", tags: ["users"], protect: true } })
.meta({
openapi: {
method: "PATCH",
path: "/api/users/changeScheme",
tags: ["users"],
protect: true,
},
})
.mutation(async ({ input, ctx }) => {
await ctx.db
.update(users)
@@ -579,7 +699,14 @@ export const userRouter = createTRPCRouter({
changeFirstDayOfWeek: protectedProcedure
.input(convertIntersectionToZodObject(userFirstDayOfWeekSchema.and(byIdSchema)))
.output(z.void())
.meta({ openapi: { method: "PATCH", path: "/api/users/firstDayOfWeek", tags: ["users"], protect: true } })
.meta({
openapi: {
method: "PATCH",
path: "/api/users/firstDayOfWeek",
tags: ["users"],
protect: true,
},
})
.mutation(async ({ input, ctx }) => {
// Only admins can change other users first day of week
if (!ctx.session.user.permissions.includes("admin") && ctx.session.user.id !== input.id) {
+10 -1
View File
@@ -18,6 +18,13 @@ const logger = createLogger({ module: "calendarRouter" });
export const calendarRouter = createTRPCRouter({
findAllEvents: publicProcedure
.meta({
mcp: {
enabled: true,
description:
"Get calendar events for upcoming and recent media releases. REQUIRED: integrationIds (array of integration IDs from integration_all, filter by kind sonarr/radarr/lidarr/readarr), year (number), month (number), releaseType (array of 'inCinemas'|'digitalRelease'|'physicalRelease'), showUnmonitored (boolean). Great for 'what's coming out this week/month?'",
},
})
.input(
z.object({
year: z.number(),
@@ -32,7 +39,9 @@ export const calendarRouter = createTRPCRouter({
ctx.integrations.map(async (integration) => {
const { integrationIds: _integrationIds, ...handlerInput } = input;
const innerHandler = calendarMonthRequestHandler.handler(integration, handlerInput);
const { data } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
const { data } = await innerHandler.getCachedOrUpdatedDataAsync({
forceUpdate: false,
});
return {
events: data,
+24 -1
View File
@@ -14,12 +14,21 @@ import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trp
export const dnsHoleRouter = createTRPCRouter({
summary: publicProcedure
.meta({
mcp: {
enabled: true,
description:
"Get DNS blocking statistics from Pi-hole/AdGuard (queries, blocked, percentage). REQUIRED: integrationIds (array of Pi-hole/AdGuard integration IDs from integration_all)",
},
})
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("dnsHole")))
.query(async ({ ctx }) => {
const results = await Promise.all(
ctx.integrations.map(async (integration) => {
const innerHandler = dnsHoleRequestHandler.handler(integration, {});
const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({
forceUpdate: false,
});
return {
integration: {
@@ -63,6 +72,13 @@ export const dnsHoleRouter = createTRPCRouter({
}),
enable: protectedProcedure
.meta({
mcp: {
enabled: true,
description:
"Enable DNS blocking on Pi-hole/AdGuard. REQUIRED: integrationId (single Pi-hole/AdGuard integration ID from integration_all)",
},
})
.concat(createOneIntegrationMiddleware("interact", ...getIntegrationKindsByCategory("dnsHole")))
.mutation(async ({ ctx: { integration } }) => {
const client = await createIntegrationAsync(integration);
@@ -76,6 +92,13 @@ export const dnsHoleRouter = createTRPCRouter({
}),
disable: protectedProcedure
.meta({
mcp: {
enabled: true,
description:
"Disable DNS blocking on Pi-hole/AdGuard. REQUIRED: integrationId (single integration ID). OPTIONAL: duration (number of seconds for temporary disable — omit for permanent disable)",
},
})
.input(
z.object({
duration: z.number().optional(),
+44 -17
View File
@@ -18,6 +18,13 @@ const createDownloadClientIntegrationMiddleware = (action: IntegrationAction) =>
export const downloadsRouter = createTRPCRouter({
getJobsAndStatuses: publicProcedure
.meta({
mcp: {
enabled: true,
description:
"Get active download jobs and queue status from connected download clients (qBittorrent, SABnzbd, Transmission, Deluge, NZBGet). REQUIRED: integrationIds (array of download client integration IDs from integration_all). OPTIONAL: limitPerIntegration (number, default 50)",
},
})
.concat(createDownloadClientIntegrationMiddleware("query"))
.input(z.object({ limitPerIntegration: z.number().default(50) }))
.query(async ({ ctx, input }) => {
@@ -25,7 +32,9 @@ export const downloadsRouter = createTRPCRouter({
ctx.integrations.map(async (integration) => {
const innerHandler = downloadClientRequestHandler.handler(integration, { limit: input.limitPerIntegration });
const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({
forceUpdate: false,
});
return {
integration: {
@@ -68,14 +77,23 @@ export const downloadsRouter = createTRPCRouter({
};
});
}),
pause: protectedProcedure.concat(createDownloadClientIntegrationMiddleware("interact")).mutation(async ({ ctx }) => {
await Promise.all(
ctx.integrations.map(async (integration) => {
const integrationInstance = await createIntegrationAsync(integration);
await integrationInstance.pauseQueueAsync();
}),
);
}),
pause: protectedProcedure
.meta({
mcp: {
enabled: true,
description:
"Pause all download queues across connected download clients. REQUIRED: integrationIds (array of download client integration IDs from integration_all)",
},
})
.concat(createDownloadClientIntegrationMiddleware("interact"))
.mutation(async ({ ctx }) => {
await Promise.all(
ctx.integrations.map(async (integration) => {
const integrationInstance = await createIntegrationAsync(integration);
await integrationInstance.pauseQueueAsync();
}),
);
}),
pauseItem: protectedProcedure
.concat(createDownloadClientIntegrationMiddleware("interact"))
.input(z.object({ item: downloadClientItemSchema }))
@@ -87,14 +105,23 @@ export const downloadsRouter = createTRPCRouter({
}),
);
}),
resume: protectedProcedure.concat(createDownloadClientIntegrationMiddleware("interact")).mutation(async ({ ctx }) => {
await Promise.all(
ctx.integrations.map(async (integration) => {
const integrationInstance = await createIntegrationAsync(integration);
await integrationInstance.resumeQueueAsync();
}),
);
}),
resume: protectedProcedure
.meta({
mcp: {
enabled: true,
description:
"Resume all download queues across connected download clients. REQUIRED: integrationIds (array of download client integration IDs from integration_all)",
},
})
.concat(createDownloadClientIntegrationMiddleware("interact"))
.mutation(async ({ ctx }) => {
await Promise.all(
ctx.integrations.map(async (integration) => {
const integrationInstance = await createIntegrationAsync(integration);
await integrationInstance.resumeQueueAsync();
}),
);
}),
resumeItem: protectedProcedure
.concat(createDownloadClientIntegrationMiddleware("interact"))
.input(z.object({ item: downloadClientItemSchema }))
@@ -9,6 +9,13 @@ import { createTRPCRouter, publicProcedure } from "../../trpc";
export const healthMonitoringRouter = createTRPCRouter({
getSystemHealthStatus: publicProcedure
.meta({
mcp: {
enabled: true,
description:
"Get system health status (CPU, memory, disk, network) from NAS/server monitoring integrations. REQUIRED: integrationIds (array of TrueNAS/Unraid/Glances/OpenMediaVault/DashDot integration IDs from integration_all)",
},
})
.concat(
createManyIntegrationMiddleware("query", "openmediavault", "dashDot", "truenas", "unraid", "glances", "mock"),
)
@@ -16,7 +23,9 @@ export const healthMonitoringRouter = createTRPCRouter({
return await Promise.all(
ctx.integrations.map(async (integration) => {
const innerHandler = systemInfoRequestHandler.handler(integration, {});
const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({
forceUpdate: false,
});
return {
integrationId: integration.id,
@@ -32,7 +41,11 @@ export const healthMonitoringRouter = createTRPCRouter({
createManyIntegrationMiddleware("query", "openmediavault", "dashDot", "truenas", "unraid", "glances", "mock"),
)
.subscription(({ ctx }) => {
return observable<{ integrationId: string; healthInfo: SystemHealthMonitoring; timestamp: Date }>((emit) => {
return observable<{
integrationId: string;
healthInfo: SystemHealthMonitoring;
timestamp: Date;
}>((emit) => {
const unsubscribes: (() => void)[] = [];
for (const integration of ctx.integrations) {
const innerHandler = systemInfoRequestHandler.handler(integration, {});
@@ -53,10 +66,19 @@ export const healthMonitoringRouter = createTRPCRouter({
});
}),
getClusterHealthStatus: publicProcedure
.meta({
mcp: {
enabled: true,
description:
"Get Proxmox cluster health status including nodes, VMs, and resource usage. REQUIRED: integrationId (single Proxmox integration ID from integration_all)",
},
})
.concat(createOneIntegrationMiddleware("query", "proxmox", "mock"))
.query(async ({ ctx }) => {
const innerHandler = clusterInfoRequestHandler.handler(ctx.integration, {});
const { data } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
const { data } = await innerHandler.getCachedOrUpdatedDataAsync({
forceUpdate: false,
});
return data;
}),
subscribeClusterHealthStatus: publicProcedure
@@ -13,12 +13,21 @@ import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trp
export const mediaRequestsRouter = createTRPCRouter({
getLatestRequests: publicProcedure
.meta({
mcp: {
enabled: true,
description:
"Get latest media requests from Overseerr/Jellyseerr with their status (pending, approved, declined, available). REQUIRED: integrationIds (array of Overseerr/Jellyseerr integration IDs from integration_all)",
},
})
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("mediaRequest")))
.query(async ({ ctx }) => {
const results = await Promise.all(
ctx.integrations.map(async (integration) => {
const innerHandler = mediaRequestListRequestHandler.handler(integration, {});
const { data } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
const { data } = await innerHandler.getCachedOrUpdatedDataAsync({
forceUpdate: false,
});
return {
integration: {
id: integration.id,
@@ -30,8 +39,13 @@ export const mediaRequestsRouter = createTRPCRouter({
}),
);
return results
.flatMap(({ data, integration }) => data.map((request) => ({ ...request, integrationId: integration.id })))
.sort((dataA, dataB) => {
.flatMap(({ data, integration }) =>
data.map((request) => ({
...request,
integrationId: integration.id,
})),
)
.toSorted((dataA, dataB) => {
if (dataA.status === dataB.status) {
return dataB.createdAt.getTime() - dataA.createdAt.getTime();
}
@@ -69,12 +83,21 @@ export const mediaRequestsRouter = createTRPCRouter({
});
}),
getStats: publicProcedure
.meta({
mcp: {
enabled: true,
description:
"Get media request statistics including total counts and top requesters. REQUIRED: integrationIds (array of Overseerr/Jellyseerr integration IDs from integration_all)",
},
})
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("mediaRequest")))
.query(async ({ ctx }) => {
const results = await Promise.all(
ctx.integrations.map(async (integration) => {
const innerHandler = mediaRequestStatsRequestHandler.handler(integration, {});
const { data } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
const { data } = await innerHandler.getCachedOrUpdatedDataAsync({
forceUpdate: false,
});
return {
integration: {
id: integration.id,
@@ -88,25 +111,42 @@ export const mediaRequestsRouter = createTRPCRouter({
return {
stats: results.flatMap((result) => result.data.stats),
users: results
.map((result) => result.data.users.map((user) => ({ ...user, integration: result.integration })))
.map((result) =>
result.data.users.map((user) => ({
...user,
integration: result.integration,
})),
)
.flat()
.sort(({ requestCount: countA }, { requestCount: countB }) => countB - countA),
.toSorted(({ requestCount: countA }, { requestCount: countB }) => countB - countA),
integrations: results.map((result) => result.integration),
};
}),
answerRequest: protectedProcedure
.meta({
mcp: {
enabled: true,
description:
"Approve or decline a pending media request. REQUIRED: integrationId (single Overseerr/Jellyseerr integration ID), requestId (number from getLatestRequests), answer ('approve' or 'decline')",
},
})
.concat(createOneIntegrationMiddleware("interact", ...getIntegrationKindsByCategory("mediaRequest")))
.input(z.object({ requestId: z.number(), answer: z.enum(["approve", "decline"]) }))
.input(
z.object({
requestId: z.number(),
answer: z.enum(["approve", "decline"]),
}),
)
.mutation(async ({ ctx: { integration }, input }) => {
const integrationInstance = await createIntegrationAsync(integration);
const innerHandler = mediaRequestListRequestHandler.handler(integration, {});
if (input.answer === "approve") {
await integrationInstance.approveRequestAsync(input.requestId);
await innerHandler.invalidateAsync();
return;
}
await integrationInstance.declineRequestAsync(input.requestId);
const answerActions = {
approve: (id: number) => integrationInstance.approveRequestAsync(id),
decline: (id: number) => integrationInstance.declineRequestAsync(id),
} as const;
await answerActions[input.answer](input.requestId);
await innerHandler.invalidateAsync();
}),
});
@@ -14,6 +14,13 @@ const createMediaServerIntegrationMiddleware = (action: IntegrationAction) =>
export const mediaServerRouter = createTRPCRouter({
getCurrentStreams: publicProcedure
.meta({
mcp: {
enabled: true,
description:
"Get currently active streams from Plex/Jellyfin/Emby media servers. REQUIRED: integrationIds (array of media server integration IDs from integration_all), showOnlyPlaying (boolean — true to filter to actively playing streams only)",
},
})
.concat(createMediaServerIntegrationMiddleware("query"))
.input(z.object({ showOnlyPlaying: z.boolean() }))
.query(async ({ ctx, input }) => {
@@ -22,7 +29,9 @@ export const mediaServerRouter = createTRPCRouter({
const innerHandler = mediaServerRequestHandler.handler(integration, {
showOnlyPlaying: input.showOnlyPlaying,
});
const { data } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
const { data } = await innerHandler.getCachedOrUpdatedDataAsync({
forceUpdate: false,
});
return {
integrationId: integration.id,
integrationKind: integration.kind,
+24 -1
View File
@@ -14,11 +14,20 @@ const createSmartHomeIntegrationMiddleware = (action: IntegrationAction) =>
export const smartHomeRouter = createTRPCRouter({
entityState: publicProcedure
.meta({
mcp: {
enabled: true,
description:
"Get the current state of a Home Assistant entity (light, switch, sensor, etc.). REQUIRED: integrationId (Home Assistant integration ID from integration_all), entityId (e.g. 'light.living_room', 'switch.fan')",
},
})
.input(z.object({ entityId: z.string() }))
.concat(createSmartHomeIntegrationMiddleware("query"))
.query(async ({ ctx: { integration }, input }) => {
const innerHandler = smartHomeEntityStateRequestHandler.handler(integration, { entityId: input.entityId });
const { data } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
const { data } = await innerHandler.getCachedOrUpdatedDataAsync({
forceUpdate: false,
});
return data;
}),
subscribeEntityState: publicProcedure
@@ -42,6 +51,13 @@ export const smartHomeRouter = createTRPCRouter({
});
}),
switchEntity: protectedProcedure
.meta({
mcp: {
enabled: true,
description:
"Toggle a Home Assistant entity (turn on/off a light, switch, etc.). REQUIRED: integrationId (Home Assistant integration ID from integration_all), entityId (e.g. 'light.living_room')",
},
})
.concat(createSmartHomeIntegrationMiddleware("interact"))
.input(z.object({ entityId: z.string() }))
.mutation(async ({ ctx: { integration }, input }) => {
@@ -54,6 +70,13 @@ export const smartHomeRouter = createTRPCRouter({
return success;
}),
executeAutomation: protectedProcedure
.meta({
mcp: {
enabled: true,
description:
"Trigger a Home Assistant automation by its ID. REQUIRED: integrationId (Home Assistant integration ID from integration_all), automationId (the automation entity ID)",
},
})
.concat(createSmartHomeIntegrationMiddleware("interact"))
.input(z.object({ automationId: z.string() }))
.mutation(async ({ ctx: { integration }, input }) => {
+3 -1
View File
@@ -1,5 +1,7 @@
import { defaultShouldDehydrateQuery, QueryClient } from "@tanstack/react-query";
import { queryCacheDefaultStaleTimeMs } from "./query-cache";
/**
* Creates a headers callback for a given source
* It will set the x-trpc-source header and cookies if needed
@@ -58,7 +60,7 @@ export const makeQueryClient = () => {
return new QueryClient({
defaultOptions: {
queries: {
staleTime: 5 * 1000,
staleTime: queryCacheDefaultStaleTimeMs,
},
dehydrate: {
shouldDehydrateQuery: (query) => defaultShouldDehydrateQuery(query) || query.state.status === "pending",
+41
View File
@@ -0,0 +1,41 @@
import { expect, test, vi } from "vitest";
import { extractToolsFromProcedures } from "trpc-to-mcp";
import { appRouter } from "../router/app";
import { infoRouter } from "../router/info";
import { inviteRouter } from "../router/invite";
import { createTRPCRouter } from "../trpc";
vi.mock("@homarr/auth", () => ({}));
const mcpTestRouter = createTRPCRouter({
app: appRouter,
info: infoRouter,
invite: inviteRouter,
});
test("MCP tools should be extractable from the router", () => {
const act = () => extractToolsFromProcedures(mcpTestRouter);
expect(act).not.toThrow();
});
test("MCP tools should contain expected procedures", () => {
const tools = extractToolsFromProcedures(mcpTestRouter);
const toolNames = tools.map((tool) => tool.name);
expect(tools.length).toBeGreaterThan(0);
expect(toolNames).toContain("app_all");
expect(toolNames).toContain("app_byId");
expect(toolNames).toContain("app_create");
expect(toolNames).toContain("info_getInfo");
expect(toolNames).toContain("invite_getAll");
});
test("MCP tools should have descriptions", () => {
const tools = extractToolsFromProcedures(mcpTestRouter);
for (const tool of tools) {
expect(tool.description, `Tool ${tool.name} should have a description`).toBeTruthy();
}
});
+224
View File
@@ -0,0 +1,224 @@
import { afterEach, describe, expect, test, vi } from "vitest";
import { QueryClient } from "@tanstack/react-query";
import type { Session } from "@homarr/auth";
import { createId } from "@homarr/common";
import type { Database } from "@homarr/db";
import { boards, users } from "@homarr/db/schema";
import { createDb } from "@homarr/db/test";
import {
isPersistableWidgetQueryKey,
queryCacheDefaultStaleTimeMs,
queryCacheMaxValueBytes,
queryCacheRetentionMs,
} from "@homarr/api/query-cache";
import { queryCacheRouter } from "../router/query-cache";
const redisMock = vi.hoisted(() => {
const values = new Map<string, { value: string; expiresAt: number }>();
const calls: {
userId: string;
boardId: string;
key: string;
ttlMs: number;
maxValueBytes: number;
}[] = [];
return {
values,
calls,
createQueryCacheChannel: vi.fn(
(options: { userId: string; boardId: string; key: string; ttlMs: number; maxValueBytes: number }) => {
calls.push(options);
const storageKey = `${options.userId}:${options.boardId}:${options.key}`;
return {
getAsync: async () => {
const item = values.get(storageKey);
if (!item) return null;
if (Date.now() > item.expiresAt) {
values.delete(storageKey);
return null;
}
return item.value;
},
setAsync: async (value: string) => {
if (Buffer.byteLength(value, "utf8") > options.maxValueBytes) {
return false;
}
values.set(storageKey, { value, expiresAt: Date.now() + options.ttlMs });
return true;
},
removeAsync: async () => {
values.delete(storageKey);
},
};
},
),
};
});
vi.mock("@homarr/redis", () => ({
createQueryCacheChannel: redisMock.createQueryCacheChannel,
}));
vi.mock("@homarr/auth", () => ({ auth: () => ({}) as Session }));
afterEach(() => {
vi.useRealTimers();
redisMock.values.clear();
redisMock.calls.length = 0;
redisMock.createQueryCacheChannel.mockClear();
});
const createSession = (userId = createId()): Session => ({
user: {
id: userId,
permissions: [],
colorScheme: "light",
},
expires: new Date().toISOString(),
});
const insertUserAsync = async (db: Database, id: string) => {
await db.insert(users).values({ id });
};
describe("isPersistableWidgetQueryKey", () => {
test.each([
[[["widget", "calendar", "findAllEvents"], { type: "query" }], true],
[[["app", "byId"], { type: "query" }], true],
[[["app", "byIds"], { type: "query" }], true],
[[["docker", "getContainers"], { type: "query" }], true],
[[["integration", "byIds"], { type: "query" }], true],
[[["widget", "app", "ping"], { type: "query", input: { id: "abc" } }], false],
[[["app", "selectable"], { type: "query" }], false],
[[["board", "getBoardByName"], { type: "query" }], false],
[["widget"], false],
])("matches persist filter for %j", (queryKey, expected) => {
expect(isPersistableWidgetQueryKey(queryKey)).toBe(expected);
});
});
describe("queryCacheRouter", () => {
test("stores, reads, and removes data for a board viewer", async () => {
const db = createDb();
const session = createSession();
await insertUserAsync(db, session.user.id);
await db.insert(boards).values({ id: "board-1", name: "board", creatorId: session.user.id, isPublic: false });
const caller = queryCacheRouter.createCaller({ db, deviceType: undefined, session });
await expect(caller.setItem({ boardId: "board-1", key: "query-key", value: "cached-value" })).resolves.toEqual({
stored: true,
});
await expect(caller.getItem({ boardId: "board-1", key: "query-key" })).resolves.toBe("cached-value");
await caller.removeItem({ boardId: "board-1", key: "query-key" });
await expect(caller.getItem({ boardId: "board-1", key: "query-key" })).resolves.toBeNull();
});
test("does not touch Redis when board access is denied", async () => {
const db = createDb();
await insertUserAsync(db, "owner");
await db.insert(boards).values({ id: "board-1", name: "board", creatorId: "owner", isPublic: false });
redisMock.createQueryCacheChannel.mockClear();
const caller = queryCacheRouter.createCaller({ db, deviceType: undefined, session: createSession("other-user") });
await expect(caller.getItem({ boardId: "board-1", key: "query-key" })).rejects.toThrow("Board not found");
expect(redisMock.createQueryCacheChannel).not.toHaveBeenCalled();
});
test("scopes anonymous public-board cache entries by anonymous user and board", async () => {
const db = createDb();
await insertUserAsync(db, "owner");
await db.insert(boards).values({ id: "board-1", name: "board", creatorId: "owner", isPublic: true });
const caller = queryCacheRouter.createCaller({ db, deviceType: undefined, session: null });
await caller.setItem({ boardId: "board-1", key: "query-key", value: "cached-value" });
expect(redisMock.calls.at(-1)).toMatchObject({
userId: "anonymous",
boardId: "board-1",
key: "query-key",
ttlMs: queryCacheRetentionMs,
maxValueBytes: queryCacheMaxValueBytes,
});
});
test("scopes signed-in cache entries by user and rejects oversized values", async () => {
const db = createDb();
const session = createSession("user-1");
await insertUserAsync(db, session.user.id);
await db.insert(boards).values({ id: "board-1", name: "board", creatorId: session.user.id, isPublic: false });
const caller = queryCacheRouter.createCaller({ db, deviceType: undefined, session });
const value = "x".repeat(queryCacheMaxValueBytes + 1);
await expect(caller.setItem({ boardId: "board-1", key: "query-key", value })).resolves.toEqual({
stored: false,
});
expect(redisMock.calls.at(-1)).toMatchObject({
userId: "user-1",
boardId: "board-1",
ttlMs: queryCacheRetentionMs,
maxValueBytes: queryCacheMaxValueBytes,
});
});
test("expires values through the Redis TTL path", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z"));
const db = createDb();
const session = createSession("user-1");
await insertUserAsync(db, session.user.id);
await db.insert(boards).values({ id: "board-1", name: "board", creatorId: session.user.id, isPublic: false });
const caller = queryCacheRouter.createCaller({ db, deviceType: undefined, session });
await caller.setItem({ boardId: "board-1", key: "query-key", value: "cached-value" });
await expect(caller.getItem({ boardId: "board-1", key: "query-key" })).resolves.toBe("cached-value");
await vi.advanceTimersByTimeAsync(queryCacheRetentionMs + 1);
await expect(caller.getItem({ boardId: "board-1", key: "query-key" })).resolves.toBeNull();
});
});
describe("query cache stale handling", () => {
test("uses TanStack Query dataUpdatedAt and staleTime to decide when cached data refetches", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-01-01T00:10:00.000Z"));
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
staleTime: queryCacheDefaultStaleTimeMs,
},
},
});
const queryKey = [["widget", "example"], { input: { integrationIds: ["integration-1"] }, type: "query" }];
const queryFn = vi.fn().mockResolvedValue("fresh");
queryClient.setQueryData(queryKey, "cached", {
updatedAt: Date.now() - queryCacheDefaultStaleTimeMs - 1,
});
await expect(
queryClient.fetchQuery({
queryKey,
queryFn,
}),
).resolves.toBe("fresh");
expect(queryFn).toHaveBeenCalledOnce();
});
});
+7 -2
View File
@@ -8,6 +8,7 @@
*/
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";
@@ -39,7 +40,11 @@ export const createTRPCContext = (opts: { headers: Headers; session: Session | n
const session = opts.session;
const source = opts.headers.get("x-trpc-source") ?? "unknown";
logger.info("Received tRPC request", { source, userId: session?.user.id, userName: session?.user.name });
logger.info("Received tRPC request", {
source,
userId: session?.user.id,
userName: session?.user.name,
});
return {
session,
@@ -56,7 +61,7 @@ export const createTRPCContext = (opts: { headers: Headers; session: Session | n
*/
const t = initTRPC
.context<typeof createTRPCContext>()
.meta<OpenApiMeta>()
.meta<OpenApiMeta & McpMeta>()
.create({
transformer: superjson,
errorFormatter: ({ shape, error }) => ({
+14 -1
View File
@@ -6,6 +6,7 @@ import { usePathname } from "next/navigation";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { getActiveQueryCacheBoardId, setActiveQueryCacheBoardId } from "@homarr/api/query-cache";
import { updateBoardName } from "./updater";
@@ -28,6 +29,8 @@ export const BoardProvider = ({
},
);
setActiveQueryCacheBoardId(data.id);
// Update the board name so it can be used within updateBoard method
updateBoardName(initialBoard.name);
@@ -42,6 +45,16 @@ export const BoardProvider = ({
};
}, [pathname, utils, initialBoard.name]);
useEffect(() => {
setActiveQueryCacheBoardId(data.id);
return () => {
if (getActiveQueryCacheBoardId() === data.id) {
setActiveQueryCacheBoardId(null);
}
};
}, [data.id]);
return (
<BoardContext.Provider
value={{
@@ -73,7 +86,7 @@ export const getCurrentLayout = (board: RouterOutputs["board"]["getBoardByName"]
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
if (typeof window === "undefined") return board.layouts.at(0)!.id;
const sortedLayouts = board.layouts.sort((layoutA, layoutB) => layoutB.breakpoint - layoutA.breakpoint);
const sortedLayouts = board.layouts.toSorted((layoutA, layoutB) => layoutB.breakpoint - layoutA.breakpoint);
// Fallback to smallest if none exists with breakpoint smaller than window width
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+2
View File
@@ -13,6 +13,8 @@ export {
handshakeAsync,
createSubPubChannel,
createGetSetChannel,
createQueryCacheChannel,
getAllQueryCacheAsync,
} from "./lib/channel";
export { createIntegrationHistoryChannel } from "./lib/channels/history-channel";
+43
View File
@@ -125,6 +125,49 @@ export const createGetSetChannel = <TData>(name: string) => {
};
};
interface QueryCacheChannelOptions {
userId: string;
boardId: string;
key: string;
ttlMs: number;
maxValueBytes: number;
}
const queryCacheHashKey = (userId: string, boardId: string) => `qc:${userId}:${boardId}`;
export const createQueryCacheChannel = ({ userId, boardId, key, ttlMs, maxValueBytes }: QueryCacheChannelOptions) => {
const hkey = queryCacheHashKey(userId, boardId);
return {
name: `${hkey}:${key}`,
getAsync: async () => {
return await getSetClient.hget(hkey, key);
},
setAsync: async (value: string) => {
if (Buffer.byteLength(value, "utf8") > maxValueBytes) {
logger.warn("Query cache value exceeded maximum size", {
channel: hkey,
valueBytes: Buffer.byteLength(value, "utf8"),
maxValueBytes,
});
return false;
}
await getSetClient.hset(hkey, key, value);
await getSetClient.pexpire(hkey, ttlMs);
return true;
},
removeAsync: async () => {
await getSetClient.hdel(hkey, key);
},
};
};
export const getAllQueryCacheAsync = async (userId: string, boardId: string): Promise<Record<string, string>> => {
const hkey = queryCacheHashKey(userId, boardId);
return await getSetClient.hgetall(hkey);
};
/**
* Creates a new cache channel.
* @param name name of the channel
+90 -1
View File
@@ -3895,7 +3895,7 @@
"docker": "Docker",
"kubernetes": "Kubernetes",
"logs": "Logs",
"api": "API",
"api": "API & MCP",
"certificates": "Certificates",
"tasks": "Tasks",
"backup": "Backup"
@@ -4436,6 +4436,95 @@
"actions": "Actions"
}
}
},
"mcp": {
"label": "AI / MCP",
"title": "AI Integration",
"subtitle": "Model Context Protocol (MCP) Server",
"description": "Connect AI assistants to your Homarr instance and control your entire homelab through natural language. Search and request media on Overseerr, manage Docker containers, toggle Pi-hole, check server health, control Home Assistant devices, and more — all from your AI assistant.",
"noApiKey": "You need an API key to authenticate with the MCP server.",
"createApiKeyButton": "Create API Key",
"endpoint": {
"title": "MCP Endpoint"
},
"authentication": {
"title": "Authentication",
"intro": "You can authenticate with your MCP server using either an API key or OAuth. Both methods grant the same level of access.",
"apiKey": {
"title": "API Key (manual)",
"description": "Pass the ApiKey header with your full API key in the MCP client configuration."
},
"oauth": {
"title": "OAuth (browser login)",
"description": "Clients that support OAuth (such as Claude Code, opencode, or Cursor) can authenticate by opening a browser window. Run the auth command in your client, log in to Homarr in the browser, and you're connected — an API key is created automatically.",
"hint": "OAuth tokens appear as regular API keys in the Authentication tab. You can revoke them at any time."
},
"permissions": "The API key determines the permissions available to the AI — it operates with the same access level as the key owner."
},
"clientConfig": {
"title": "Client Configuration",
"streamableHttp": {
"recommended": "Recommended",
"title": "Streamable HTTP",
"worksWith": "Works with: Cursor, Claude Desktop, VS Code Copilot, Windsurf, Claude Code",
"addToConfig": "Add to your MCP client configuration file:",
"cursor": "{name}: {file} in your project or global settings",
"claudeDesktop": "{name}: {file}",
"claudeCode": "{name}: {file} or use {command}",
"vsCodeCopilot": "{name}: {file} in workspace settings",
"replaceHint": "Replace the placeholder with the API key you received when creating it."
},
"stdio": {
"title": "stdio (legacy clients via mcp-remote bridge)",
"description": "For clients that only support stdio transport. Requires {dependency} (Node.js) installed locally:"
}
},
"testConnection": {
"title": "Test Your Connection",
"description": "Run this in your terminal to verify the endpoint is reachable and your API key works:",
"hint": "A successful response returns the MCP server capabilities. If you see an error, check the JSON response for hints."
},
"availableTools": {
"title": "Available Tools",
"description": "{count} tools available. Expand a category to see individual tools and their descriptions.",
"columnType": "Type",
"columnName": "Tool",
"columnDescription": "Description"
},
"troubleshooting": {
"title": "Troubleshooting",
"missingHeader": {
"title": "\"Missing ApiKey header\"",
"description": "Your client is not sending the {header} header. Verify the {section} section in your MCP config is correct. Some clients require restarting after config changes."
},
"invalidFormat": {
"title": "\"Invalid ApiKey format\"",
"description": "Make sure you copied the full API key, not just part of it."
},
"invalidKey": {
"title": "\"Invalid API key\"",
"description": "The key ID was not found or the token is wrong. API keys can only be viewed once when created. If you lost it, delete the old key and create a new one."
},
"connectionRefused": {
"title": "Connection refused / timeout",
"description": "Make sure the URL is reachable from your AI client. If Homarr runs behind a reverse proxy or in Docker, use the external URL (not {localhost}). Verify the port is correct (default: 7575 for Docker)."
},
"toolsNotAppearing": {
"title": "Tools not appearing",
"description": "Some tools require specific integrations to be configured (e.g. Docker, Overseerr). The tool list depends on what's available in your Homarr instance."
}
},
"copy": "Copy",
"oneClick": {
"title": "Install in One Click",
"subtitle": "Uses OAuth — authenticate in your browser on first use",
"vsCode": "Install in VS Code",
"cursor": "Install in Cursor",
"copyUrl": "Copy MCP URL",
"copied": "Copied!",
"copyHint": "For ChatGPT, Claude Code, or any MCP client",
"manualConfig": "Manual Configuration"
}
}
}
},
+25
View File
@@ -5,6 +5,7 @@ import {
backgroundImageRepeats,
backgroundImageSizes,
boardPermissions,
widgetKinds,
} from "@homarr/definitions";
import { zodEnumFromArray } from "./enums";
@@ -94,3 +95,27 @@ export const boardCreateSchema = z.object({
});
export const boardSavePermissionsSchema = createSavePermissionsSchema(zodEnumFromArray(boardPermissions));
export const boardSummarySchema = z.object({
id: z.string(),
name: z.string(),
logoImageUrl: z.string().nullable(),
isPublic: z.boolean(),
creator: z
.object({
id: z.string(),
name: z.string().nullable(),
image: z.string().nullable(),
email: z.string().nullable(),
})
.nullable(),
isHome: z.boolean(),
isMobileHome: z.boolean(),
});
export const addItemToBoardSchema = z.object({
boardId: z.string(),
kind: zodEnumFromArray(widgetKinds),
options: z.record(z.string(), z.unknown()).default({}),
integrationIds: z.array(z.string()).default([]),
});
+21 -16
View File
@@ -14,6 +14,7 @@ import { useTimeAgo } from "@homarr/common";
import type { AnchorNotePermission } from "@homarr/integrations";
import { useScopedI18n } from "@homarr/translation/client";
import { WidgetEmptyState } from "../common/empty-state";
import type { WidgetComponentProps } from "../definition";
import "react-quill-new/dist/quill.snow.css";
@@ -144,13 +145,13 @@ interface AnchorNoteWidgetContentProps {
const AnchorNoteWidgetContent = ({ options, integrationId, noteId }: AnchorNoteWidgetContentProps) => {
const t = useScopedI18n("widget.anchorNote");
const [note, { refetch }] = clientApi.widget.anchorNotes.getNote.useSuspenseQuery(
const { data: note, refetch } = clientApi.widget.anchorNotes.getNote.useQuery(
{
integrationId,
noteId,
},
{
refetchOnMount: false,
staleTime: 15 * 1000,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
retry: false,
@@ -159,31 +160,32 @@ const AnchorNoteWidgetContent = ({ options, integrationId, noteId }: AnchorNoteW
const { mutateAsync: updateNoteAsync, isPending: isUpdating } = clientApi.widget.anchorNotes.updateNote.useMutation();
const [isEditing, setIsEditing] = useState(false);
const [draftTitle, setDraftTitle] = useState(note.title);
const [draftContent, setDraftContent] = useState(note.content ?? "");
const [draftTitle, setDraftTitle] = useState(note?.title ?? "");
const [draftContent, setDraftContent] = useState(note?.content ?? "");
const [saveError, setSaveError] = useState<string | null>(null);
useEffect(() => {
if (isEditing) return;
if (isEditing || !note) return;
startTransition(() => {
setDraftTitle(note.title);
setDraftContent(note.content ?? "");
});
}, [isEditing, note.content, note.title]);
}, [isEditing, note]);
const canEdit = canEditPermission(note.permission);
const isViewer = note.permission === "viewer";
const updatedAt = useMemo(() => new Date(note.updatedAt), [note.updatedAt]);
const canEdit = canEditPermission(note?.permission ?? "viewer");
const isViewer = !note || note.permission === "viewer";
const updatedAt = useMemo(() => (note ? new Date(note.updatedAt) : new Date()), [note]);
const updatedAtRelative = useTimeAgo(updatedAt, 30000);
const hasChanges = useMemo(() => {
if (!note) return false;
const normalizedTitle = draftTitle.trim() || note.title;
return normalizedTitle !== note.title || draftContent !== (note.content ?? "");
}, [draftContent, draftTitle, note.content, note.title]);
}, [draftContent, draftTitle, note]);
const editorValue = useMemo(() => parseStoredContent(draftContent), [draftContent]);
const readOnlyValue = useMemo(() => parseStoredContent(note.content), [note.content]);
const readOnlyValue = useMemo(() => parseStoredContent(note?.content), [note?.content]);
const handleEditorChange = useCallback<ReactQuillOnChange>(
(_html, _delta, source, editor) => {
if (!isEditing || source !== "user") return;
@@ -194,26 +196,27 @@ const AnchorNoteWidgetContent = ({ options, integrationId, noteId }: AnchorNoteW
);
const handleEdit = useCallback(() => {
if (!canEdit) return;
if (!canEdit || !note) return;
startTransition(() => {
setDraftTitle(note.title);
setDraftContent(note.content ?? "");
setSaveError(null);
setIsEditing(true);
});
}, [canEdit, note.content, note.title]);
}, [canEdit, note]);
const handleCancel = useCallback(() => {
if (!note) return;
startTransition(() => {
setDraftTitle(note.title);
setDraftContent(note.content ?? "");
setSaveError(null);
setIsEditing(false);
});
}, [note.content, note.title]);
}, [note]);
const handleSave = useCallback(async () => {
if (!canEdit) return;
if (!canEdit || !note) return;
const normalizedTitle = draftTitle.trim() || note.title || t("untitled");
@@ -260,7 +263,9 @@ const AnchorNoteWidgetContent = ({ options, integrationId, noteId }: AnchorNoteW
},
},
);
}, [canEdit, draftContent, draftTitle, hasChanges, integrationId, note.title, noteId, refetch, t, updateNoteAsync]);
}, [canEdit, draftContent, draftTitle, hasChanges, integrationId, note, noteId, refetch, t, updateNoteAsync]);
if (!note) return <WidgetEmptyState />;
return (
<Stack h="100%" gap="xs" p="sm">
+8 -7
View File
@@ -3,7 +3,7 @@
import type { PropsWithChildren } from "react";
import { Fragment, Suspense } from "react";
import { Flex, rem, Stack, Text, Tooltip, UnstyledButton } from "@mantine/core";
import { IconLoader } from "@tabler/icons-react";
import { IconMinus } from "@tabler/icons-react";
import combineClasses from "clsx";
import { clientApi } from "@homarr/api/client";
@@ -13,6 +13,7 @@ import { useRegisterSpotlightContextResults } from "@homarr/spotlight";
import { useI18n } from "@homarr/translation/client";
import { MaskedOrNormalImage } from "@homarr/ui";
import { WidgetEmptyState } from "../common/empty-state";
import type { WidgetComponentProps } from "../definition";
import classes from "./app.module.css";
import { PingDot } from "./ping/ping-dot";
@@ -22,20 +23,19 @@ export default function AppWidget({ options, isEditMode, height, width }: Widget
const t = useI18n();
const settings = useSettings();
const board = useRequiredBoard();
const [app] = clientApi.app.byId.useSuspenseQuery(
const { data: app } = clientApi.app.byId.useQuery(
{
id: options.appId,
},
{
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
retry: false,
},
);
useRegisterSpotlightContextResults(
`app-${app.id}`,
app.href
`app-${app?.id ?? options.appId}`,
app?.href
? [
{
id: app.id,
@@ -44,7 +44,6 @@ export default function AppWidget({ options, isEditMode, height, width }: Widget
interaction() {
return {
type: "link",
// We checked above that app.href is defined
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
href: app.href!,
newTab: options.openInNewTab,
@@ -56,6 +55,8 @@ export default function AppWidget({ options, isEditMode, height, width }: Widget
[app, options.openInNewTab],
);
if (!app) return <WidgetEmptyState />;
const isTiny = height < 100 || width < 100;
const isColumnLayout = options.layout.startsWith("column");
@@ -130,7 +131,7 @@ export default function AppWidget({ options, isEditMode, height, width }: Widget
</Flex>
</Tooltip.Floating>
{options.pingEnabled && !settings.forceDisableStatus && !board.disableStatus && app.href ? (
<Suspense fallback={<PingDot icon={IconLoader} color="blue" tooltip={`${t("common.action.loading")}…`} />}>
<Suspense fallback={<PingDot icon={IconMinus} color="gray" tooltip={`${t("common.action.loading")}…`} />}>
<PingIndicator appId={app.id} />
</Suspense>
) : null}
@@ -1,8 +1,7 @@
import { useState } from "react";
import { IconCheck, IconX } from "@tabler/icons-react";
import { IconCheck, IconMinus, IconX } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { useI18n } from "@homarr/translation/client";
import { PingDot } from "./ping-dot";
@@ -11,27 +10,24 @@ interface PingIndicatorProps {
}
export const PingIndicator = ({ appId }: PingIndicatorProps) => {
const [ping] = clientApi.widget.app.ping.useSuspenseQuery(
{
id: appId,
},
{
refetchOnMount: false,
refetchOnWindowFocus: false,
},
const t = useI18n();
const { data: pingResult } = clientApi.widget.app.ping.useQuery(
{ id: appId },
{ refetchOnMount: false, refetchOnWindowFocus: false },
);
const [pingResult, setPingResult] = useState<RouterOutputs["widget"]["app"]["ping"]>(ping);
const utils = clientApi.useUtils();
clientApi.widget.app.updatedPing.useSubscription(
{ id: appId },
{
onData(data) {
setPingResult(data);
utils.widget.app.ping.setData({ id: appId }, data);
},
},
);
if (!pingResult) return <PingDot icon={IconMinus} color="gray" tooltip={`${t("common.action.loading")}…`} />;
const isError = "error" in pingResult || pingResult.statusCode >= 500;
return (
@@ -2,13 +2,17 @@
import { clientApi } from "@homarr/api/client";
import { WidgetEmptyState } from "../common/empty-state";
import type { WidgetComponentProps } from "../definition";
import { AudioStatsContent } from "./audio-stats-content";
export default function AudioStatsWidget({ integrationIds, options, width }: WidgetComponentProps<"audioStats">) {
const [response] = clientApi.widget.audioStats.getStats.useSuspenseQuery({
integrationId: integrationIds[0] ?? "",
});
const { data: response } = clientApi.widget.audioStats.getStats.useQuery(
{ integrationId: integrationIds[0] ?? "" },
{ staleTime: 5 * 60 * 1000 },
);
if (!response) return <WidgetEmptyState />;
return <AudioStatsContent backend={response.kind} stats={response.data} options={options} width={width} />;
}
@@ -36,11 +36,14 @@ export default function BeszelAlertsWidget({
isEditMode,
}: WidgetComponentProps<"beszelAlerts">) {
const t = useScopedI18n("widget.beszelAlerts");
const [results] = clientApi.widget.beszel.getAlerts.useSuspenseQuery({
integrationIds,
includeHistory: options.showHistory,
maxHistoryItems: options.maxHistoryItems,
});
const { data: results = [] } = clientApi.widget.beszel.getAlerts.useQuery(
{
integrationIds,
includeHistory: options.showHistory,
maxHistoryItems: options.maxHistoryItems,
},
{ staleTime: 30 * 1000 },
);
const utils = clientApi.useUtils();
clientApi.widget.beszel.subscribeAlerts.useSubscription(
@@ -344,7 +344,10 @@ export default function BeszelSystemGridWidget({
height,
}: WidgetComponentProps<"beszelSystemGrid">) {
const t = useScopedI18n("widget.beszelSystemGrid");
const [results] = clientApi.widget.beszel.getSystems.useSuspenseQuery({ integrationIds });
const { data: results = [] } = clientApi.widget.beszel.getSystems.useQuery(
{ integrationIds },
{ staleTime: 30 * 1000 },
);
useBeszelSystemsSubscription(integrationIds, !isEditMode);
const filteredSystems = useBeszelFilteredSystems(results, options.statusFilter);
@@ -39,7 +39,10 @@ export default function BeszelSystemStatsWidget({
width,
}: WidgetComponentProps<"beszelSystemStats">) {
const t = useScopedI18n("widget.beszelSystemStats");
const [systemsResult] = clientApi.widget.beszel.getSystems.useSuspenseQuery({ integrationIds });
const { data: systemsResult = [] } = clientApi.widget.beszel.getSystems.useQuery(
{ integrationIds },
{ staleTime: 30 * 1000 },
);
const systems = useMemo(
() => systemsResult.flatMap((r) => r.systems.map((s) => ({ value: s.id, label: s.name }))),
@@ -50,12 +53,15 @@ export default function BeszelSystemStatsWidget({
const showDocker = options.showDockerCpu || options.showDockerMemory || options.showDockerNetwork;
const [statsResult] = clientApi.widget.beszel.getSystemStats.useSuspenseQuery({
integrationIds,
systemId: selectedSystem ?? systems[0]?.value ?? "",
timePeriod: options.timePeriod as "1h" | "12h" | "24h" | "1w" | "30d",
includeDocker: showDocker,
});
const { data: statsResult } = clientApi.widget.beszel.getSystemStats.useQuery(
{
integrationIds,
systemId: selectedSystem ?? systems[0]?.value ?? "",
timePeriod: options.timePeriod as "1h" | "12h" | "24h" | "1w" | "30d",
includeDocker: showDocker,
},
{ staleTime: 30 * 1000 },
);
// BeszelSystemStats fields are documented in beszel-types.ts.
// mu/du = bytes, dr/dw = bytes/s, b = public bandwidth [sent,recv] bytes/s, ns/nr = all-interface bytes/s
@@ -54,7 +54,10 @@ export default function BeszelSystemTableWidget({
width,
}: WidgetComponentProps<"beszelSystemTable">) {
const t = useScopedI18n("widget.beszelSystemTable");
const [results] = clientApi.widget.beszel.getSystems.useSuspenseQuery({ integrationIds });
const { data: results = [] } = clientApi.widget.beszel.getSystems.useQuery(
{ integrationIds },
{ staleTime: 30 * 1000 },
);
const size = getSizeConfig(width);
useBeszelSystemsSubscription(integrationIds, !isEditMode);
+1 -1
View File
@@ -14,7 +14,7 @@ import classes from "./bookmark.module.css";
export default function BookmarksWidget({ options, itemId }: WidgetComponentProps<"bookmarks">) {
const board = useRequiredBoard();
const [data] = clientApi.app.byIds.useSuspenseQuery(options.items, {
const { data = [] } = clientApi.app.byIds.useQuery(options.items, {
select(data) {
return data.toSorted((appA, appB) => options.items.indexOf(appA.id) - options.items.indexOf(appB.id));
},
+4 -7
View File
@@ -39,8 +39,8 @@ const FetchCalendar = ({ month, setMonth, isEditMode, integrationIds, options }:
releaseType: options.releaseType,
showUnmonitored: options.showUnmonitored,
};
const [data] = clientApi.widget.calendar.findAllEvents.useSuspenseQuery(input, {
refetchOnMount: false,
const { data } = clientApi.widget.calendar.findAllEvents.useQuery(input, {
staleTime: 60 * 1000,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
retry: false,
@@ -61,7 +61,7 @@ const FetchCalendar = ({ month, setMonth, isEditMode, integrationIds, options }:
},
});
const events = useMemo(() => data.flatMap((item) => item.events), [data]);
const events = useMemo(() => data?.flatMap((item) => item.events) ?? [], [data]);
return <CalendarBase isEditMode={isEditMode} events={events} month={month} setMonth={setMonth} options={options} />;
};
@@ -152,13 +152,10 @@ const CalendarBase = ({ isEditMode, events, month, setMonth, options }: Calendar
.filter(
(event) => event.metadata?.type !== "radarr" || options.releaseType.includes(event.metadata.releaseType),
)
.sort((eventA, eventB) => eventA.startDate.getTime() - eventB.startDate.getTime());
.toSorted((eventA, eventB) => eventA.startDate.getTime() - eventB.startDate.getTime());
return (
<CalendarDay
// new Date() does not work here, because for timezones like UTC-7 it will
// show one day earlier (probably due to the time being set to 00:00)
// see https://github.com/homarr-labs/homarr/pull/3120
date={dayjs(tileDate).toDate()}
events={eventsForDate}
disabled={isEditMode || eventsForDate.length === 0}
@@ -0,0 +1,15 @@
import { Center, Text } from "@mantine/core";
import { useI18n } from "@homarr/translation/client";
export const WidgetEmptyState = () => {
const t = useI18n();
return (
<Center h="100%" w="100%" p="sm">
<Text c="dimmed" size="sm" ta="center">
{t("widget.common.error.noData")}
</Text>
</Center>
);
};
+4 -1
View File
@@ -31,7 +31,10 @@ interface CoolifyContentProps {
}
function CoolifyContent({ integrationIds, options, width }: CoolifyContentProps) {
const [instancesData] = clientApi.widget.coolify.getInstancesInfo.useSuspenseQuery({ integrationIds });
const { data: instancesData = [] } = clientApi.widget.coolify.getInstancesInfo.useQuery(
{ integrationIds },
{ staleTime: 30 * 1000 },
);
const utils = clientApi.useUtils();
clientApi.widget.coolify.subscribeInstancesInfo.useSubscription(
@@ -51,12 +51,12 @@ export default function DnsHoleControlsWidget({
.map(({ id }) => id)
.filter((id) => integrationIds.includes(id));
const [summaries] = clientApi.widget.dnsHole.summary.useSuspenseQuery(
const { data: summaries = [] } = clientApi.widget.dnsHole.summary.useQuery(
{
integrationIds,
},
{
refetchOnMount: false,
staleTime: 5 * 1000,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
retry: false,
@@ -20,12 +20,12 @@ import type { widgetKind } from ".";
import type { WidgetComponentProps, WidgetProps } from "../../definition";
export default function DnsHoleSummaryWidget({ options, integrationIds }: WidgetComponentProps<typeof widgetKind>) {
const [summaries] = clientApi.widget.dnsHole.summary.useSuspenseQuery(
const { data: summaries = [] } = clientApi.widget.dnsHole.summary.useQuery(
{
integrationIds,
},
{
refetchOnMount: false,
staleTime: 5 * 1000,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
retry: false,
+5 -1
View File
@@ -185,7 +185,11 @@ export default function DockerWidget({ options, width, isEditMode }: WidgetCompo
const isTiny = width <= 256;
const utils = clientApi.useUtils();
const [{ containers, timestamp }] = clientApi.docker.getContainers.useSuspenseQuery();
const { data } = clientApi.docker.getContainers.useQuery(undefined, {
staleTime: 20 * 1000,
});
const containers = data?.containers ?? [];
const timestamp = useMemo(() => data?.timestamp ?? new Date(), [data?.timestamp]);
const relativeTime = useTimeAgo(timestamp);
clientApi.docker.subscribeContainers.useSubscription(undefined, {
+2 -3
View File
@@ -87,13 +87,13 @@ export default function DownloadClientsWidget({
integrationIds.includes(id) ? [id] : [],
);
const [currentItems] = clientApi.widget.downloads.getJobsAndStatuses.useSuspenseQuery(
const { data: currentItems = [] } = clientApi.widget.downloads.getJobsAndStatuses.useQuery(
{
integrationIds,
limitPerIntegration: options.limitPerIntegration,
},
{
refetchOnMount: false,
staleTime: 5 * 1000,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
retry: false,
@@ -101,7 +101,6 @@ export default function DownloadClientsWidget({
);
const utils = clientApi.useUtils();
//Translations
const t = useScopedI18n("widget.downloads");
const tCommon = useScopedI18n("common");
+1 -1
View File
@@ -28,7 +28,7 @@ const columnsList = [
] as const satisfies (keyof ExtendedDownloadClientItem)[];
const sortingExclusion = ["actions", "id", "state"] as const satisfies readonly (typeof columnsList)[number][];
const columnsSort = columnsList.filter((column) =>
sortingExclusion.some((exclusion) => exclusion !== column),
sortingExclusion.every((exclusion) => exclusion !== column),
) as Exclude<typeof columnsList, (typeof sortingExclusion)[number]>;
export const { definition, componentLoader } = createWidgetDefinition("downloads", {
@@ -0,0 +1,29 @@
import { api, trpc } from "@homarr/api/server";
import { createLogger } from "@homarr/core/infrastructure/logs";
import type { Prefetch } from "../definition";
const logger = createLogger({ module: "downloadsWidgetPrefetch" });
const prefetchAllAsync: Prefetch<"downloads"> = async (queryClient, items) => {
await Promise.all(
items.map(async (item) => {
if (item.integrationIds.length === 0) return;
const input = {
integrationIds: item.integrationIds,
limitPerIntegration: Number(item.options.limitPerIntegration) || 50,
};
try {
const data = await api.widget.downloads.getJobsAndStatuses(input);
queryClient.setQueryData(trpc.widget.downloads.getJobsAndStatuses.queryKey(input), data);
logger.info("Prefetched downloads widget data", { integrations: item.integrationIds.length });
} catch (error) {
logger.warn("Failed to prefetch downloads widget data", { error });
}
}),
);
};
export default prefetchAllAsync;
+8 -8
View File
@@ -184,12 +184,12 @@ export default function FirewallWidget({ integrationIds, width, itemId }: Widget
export const useUpdatingCpuStatus = (integrationIds: string[]) => {
const utils = clientApi.useUtils();
const [firewallsCpuData] = clientApi.widget.firewall.getFirewallCpuStatus.useSuspenseQuery(
const { data: firewallsCpuData = [] } = clientApi.widget.firewall.getFirewallCpuStatus.useQuery(
{
integrationIds,
},
{
refetchOnMount: false,
staleTime: 5 * 1000,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
retry: false,
@@ -225,12 +225,12 @@ export const useUpdatingCpuStatus = (integrationIds: string[]) => {
export const useUpdatingMemoryStatus = (integrationIds: string[]) => {
const utils = clientApi.useUtils();
const [firewallsMemoryData] = clientApi.widget.firewall.getFirewallMemoryStatus.useSuspenseQuery(
const { data: firewallsMemoryData = [] } = clientApi.widget.firewall.getFirewallMemoryStatus.useQuery(
{
integrationIds,
},
{
refetchOnMount: false,
staleTime: 15 * 1000,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
retry: false,
@@ -266,12 +266,12 @@ export const useUpdatingMemoryStatus = (integrationIds: string[]) => {
export const useUpdatingVersionStatus = (integrationIds: string[]) => {
const utils = clientApi.useUtils();
const [firewallsVersionData] = clientApi.widget.firewall.getFirewallVersionStatus.useSuspenseQuery(
const { data: firewallsVersionData = [] } = clientApi.widget.firewall.getFirewallVersionStatus.useQuery(
{
integrationIds,
},
{
refetchOnMount: false,
staleTime: 60 * 60 * 1000,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
retry: false,
@@ -306,12 +306,12 @@ export const useUpdatingVersionStatus = (integrationIds: string[]) => {
export const useUpdatingInterfacesStatus = (integrationIds: string[]) => {
const utils = clientApi.useUtils();
const [firewallsInterfacesData] = clientApi.widget.firewall.getFirewallInterfacesStatus.useSuspenseQuery(
const { data: firewallsInterfacesData = [] } = clientApi.widget.firewall.getFirewallInterfacesStatus.useQuery(
{
integrationIds,
},
{
refetchOnMount: false,
staleTime: 30 * 1000,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
retry: false,
@@ -5,6 +5,7 @@ import { clientApi } from "@homarr/api/client";
import type { Resource } from "@homarr/integrations/types";
import { useI18n } from "@homarr/translation/client";
import { WidgetEmptyState } from "../../common/empty-state";
import type { WidgetComponentProps } from "../../definition";
import { formatUptime } from "../system-health";
import { ResourceAccordionItem } from "./resource-accordion-item";
@@ -34,12 +35,12 @@ export const ClusterHealthMonitoring = ({
width,
}: WidgetComponentProps<"healthMonitoring"> & { integrationId: string }) => {
const t = useI18n();
const [healthData] = clientApi.widget.healthMonitoring.getClusterHealthStatus.useSuspenseQuery(
const { data: healthData } = clientApi.widget.healthMonitoring.getClusterHealthStatus.useQuery(
{
integrationId,
},
{
refetchOnMount: false,
staleTime: 5 * 1000,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
retry: false,
@@ -56,6 +57,8 @@ export const ClusterHealthMonitoring = ({
},
);
if (!healthData) return <WidgetEmptyState />;
const activeNodes = healthData.nodes.reduce(running, 0);
const activeVMs = healthData.vms.reduce(running, 0);
const activeLXCs = healthData.lxcs.reduce(running, 0);
@@ -18,7 +18,7 @@ const isClusterIntegration = (integration: { kind: IntegrationKind }) =>
integration.kind === "proxmox" || integration.kind === "mock";
export default function HealthMonitoringWidget(props: WidgetComponentProps<"healthMonitoring">) {
const [integrations] = clientApi.integration.byIds.useSuspenseQuery(props.integrationIds);
const { data: integrations = [] } = clientApi.integration.byIds.useQuery(props.integrationIds);
const t = useI18n();
const clusterIntegrationId = integrations.find(isClusterIntegration)?.id;
@@ -37,6 +37,7 @@ import type { TranslationFunction } from "@homarr/translation";
import { useI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../definition";
import { WidgetEmptyState } from "../common/empty-state";
import { CpuRing } from "./rings/cpu-ring";
import { CpuTempRing } from "./rings/cpu-temp-ring";
import { GpuRing } from "./rings/gpu-ring";
@@ -51,12 +52,12 @@ export const SystemHealthMonitoring = ({
width,
}: WidgetComponentProps<"healthMonitoring">) => {
const t = useI18n();
const [healthData] = clientApi.widget.healthMonitoring.getSystemHealthStatus.useSuspenseQuery(
const { data: healthData = [] } = clientApi.widget.healthMonitoring.getSystemHealthStatus.useQuery(
{
integrationIds,
},
{
refetchOnMount: false,
staleTime: 5 * 1000,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
retry: false,
@@ -86,6 +87,8 @@ export const SystemHealthMonitoring = ({
const isTiny = width < 256;
if (healthData.length === 0) return <WidgetEmptyState />;
return (
<Stack h="100%" gap="sm" className="health-monitoring">
{healthData.map(({ integrationId, integrationName, healthInfo, updatedAt }) => {
@@ -332,5 +335,5 @@ export const matchFileSystemAndSmart = (fileSystems: FileSystem[], smartData: Sm
overallStatus: smartDisk?.overallStatus ?? "",
};
})
.sort((fileSystemA, fileSystemB) => fileSystemA.deviceName.localeCompare(fileSystemB.deviceName));
.toSorted((fileSystemA, fileSystemB) => fileSystemA.deviceName.localeCompare(fileSystemB.deviceName));
};
@@ -8,6 +8,7 @@ import { IconAlertCircle, IconCalendar } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { useI18n } from "@homarr/translation/client";
import { WidgetEmptyState } from "../../common/empty-state";
import type { WidgetComponentProps } from "../../definition";
import classes from "./component.module.css";
@@ -17,14 +18,19 @@ export default function ImmichAlbumCarouselWidget({
}: WidgetComponentProps<"immich-albumCarousel">) {
const [currentPhotoIndex, setCurrentPhotoIndex] = useState(0);
const { data: album } = clientApi.widget.immich.getAlbum.useQuery(
{
integrationId: integrationIds[0] ?? "",
albumId: options.albumId ?? "",
},
{ enabled: Boolean(options.albumId), staleTime: 15 * 60 * 1000 },
);
if (!options.albumId) {
return <NoAlbumSelected />;
}
const [album] = clientApi.widget.immich.getAlbum.useSuspenseQuery({
integrationId: integrationIds[0] ?? "",
albumId: options.albumId,
});
if (!album) return <WidgetEmptyState />;
if (album.assets.length === 0) {
return <NoPhotosInAlbum />;
@@ -8,6 +8,7 @@ import { clientApi } from "@homarr/api/client";
import { humanFileSize } from "@homarr/common";
import { useI18n } from "@homarr/translation/client";
import { WidgetEmptyState } from "../../common/empty-state";
import type { WidgetComponentProps } from "../../definition";
import classes from "./component.module.css";
@@ -16,9 +17,14 @@ export default function ImmichServerStatsWidget({
options,
}: WidgetComponentProps<"immich-serverStats">) {
const t = useI18n();
const [stats] = clientApi.widget.immich.getServerStats.useSuspenseQuery({
integrationId: integrationIds[0] ?? "",
});
const { data: stats } = clientApi.widget.immich.getServerStats.useQuery(
{
integrationId: integrationIds[0] ?? "",
},
{ staleTime: 15 * 60 * 1000 },
);
if (!stats) return <WidgetEmptyState />;
return (
<Stack gap="md" h="100%" p="md">
+1
View File
@@ -60,6 +60,7 @@ import * as weather from "./weather";
export type { WidgetDefinition, WidgetOptionsSettings } from "./definition";
export type { WidgetComponentProps };
export type { WidgetOptionDefinition, WidgetOptionType } from "./options";
export const widgetImports = {
clock,
@@ -18,10 +18,10 @@ export default function IndexerManagerWidget({
height,
}: WidgetComponentProps<"indexerManager">) {
const t = useI18n();
const [indexersData] = clientApi.widget.indexerManager.getIndexersStatus.useSuspenseQuery(
const { data: indexersData = [] } = clientApi.widget.indexerManager.getIndexersStatus.useQuery(
{ integrationIds },
{
refetchOnMount: false,
staleTime: 5 * 60 * 1000,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
retry: false,
@@ -18,9 +18,12 @@ import { OverflowBadge } from "@homarr/ui";
import type { WidgetComponentProps } from "../definition";
export default function MediaReleasesWidget({ options, integrationIds }: WidgetComponentProps<"mediaReleases">) {
const [releases] = clientApi.widget.mediaRelease.getMediaReleases.useSuspenseQuery({
integrationIds,
});
const { data: releases = [] } = clientApi.widget.mediaRelease.getMediaReleases.useQuery(
{
integrationIds,
},
{ staleTime: 5 * 60 * 1000 },
);
return (
<Stack p="xs" gap="sm">
@@ -24,6 +24,7 @@ import { mediaAvailabilityConfiguration, mediaRequestStatusConfiguration } from
import { openMediaRequestSearch } from "@homarr/spotlight";
import { useScopedI18n } from "@homarr/translation/client";
import { WidgetEmptyState } from "../../common/empty-state";
import type { WidgetComponentProps } from "../../definition";
import { NoIntegrationDataError } from "../../errors/no-data-integration";
import classes from "../search-button.module.css";
@@ -34,12 +35,12 @@ export default function MediaServerWidget({
options,
width,
}: WidgetComponentProps<"mediaRequests-requestList">) {
const [mediaRequests] = clientApi.widget.mediaRequests.getLatestRequests.useSuspenseQuery(
const { data: mediaRequests } = clientApi.widget.mediaRequests.getLatestRequests.useQuery(
{
integrationIds,
},
{
refetchOnMount: false,
staleTime: 60 * 1000,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
},
@@ -73,6 +74,7 @@ export default function MediaServerWidget({
},
);
if (!mediaRequests) return <WidgetEmptyState />;
if (mediaRequests.length === 0) throw new NoIntegrationDataError();
return (
@@ -21,6 +21,7 @@ import type { RequestStats } from "@homarr/integrations/types";
import { openMediaRequestSearch } from "@homarr/spotlight";
import { useScopedI18n } from "@homarr/translation/client";
import { WidgetEmptyState } from "../../common/empty-state";
import type { WidgetComponentProps } from "../../definition";
import { NoIntegrationDataError } from "../../errors/no-data-integration";
import classes from "./component.module.css";
@@ -35,12 +36,12 @@ export default function MediaServerWidget({
width,
}: WidgetComponentProps<"mediaRequests-requestStats">) {
const t = useScopedI18n("widget.mediaRequests-requestStats");
const [requestStats] = clientApi.widget.mediaRequests.getStats.useSuspenseQuery(
const { data: requestStats } = clientApi.widget.mediaRequests.getStats.useQuery(
{
integrationIds,
},
{
refetchOnMount: false,
staleTime: 60 * 1000,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
},
@@ -48,6 +49,7 @@ export default function MediaServerWidget({
const board = useRequiredBoard();
if (!requestStats) return <WidgetEmptyState />;
if (requestStats.users.length === 0 && requestStats.stats.length === 0) throw new NoIntegrationDataError();
const data = [
@@ -23,13 +23,13 @@ export default function MediaServerWidget({
integrationIds,
isEditMode,
}: WidgetComponentProps<"mediaServer">) {
const [currentStreams] = clientApi.widget.mediaServer.getCurrentStreams.useSuspenseQuery(
const { data: currentStreams = [] } = clientApi.widget.mediaServer.getCurrentStreams.useQuery(
{
integrationIds,
showOnlyPlaying: options.showOnlyPlaying,
},
{
refetchOnMount: false,
staleTime: 5 * 1000,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
},
@@ -9,6 +9,7 @@ import { useI18n } from "@homarr/translation/client";
import type { TablerIcon } from "@homarr/ui";
import { views } from ".";
import { WidgetEmptyState } from "../common/empty-state";
import type { WidgetComponentProps } from "../definition";
import { HealthCheckStatus } from "./health-check-status";
import { QueuePanel } from "./panels/queue.panel";
@@ -37,8 +38,8 @@ export default function MediaTranscodingWidget({
pageSize: queuePageSize,
page: queuePage,
};
const [transcodingData] = clientApi.widget.mediaTranscoding.getDataAsync.useSuspenseQuery(input, {
refetchOnMount: false,
const { data: transcodingData } = clientApi.widget.mediaTranscoding.getDataAsync.useQuery(input, {
staleTime: 5 * 60 * 1000,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
});
@@ -51,9 +52,11 @@ export default function MediaTranscodingWidget({
});
const [view, setView] = useState<View>(options.defaultView);
const totalQueuePages = Math.ceil((transcodingData.data.queue.totalCount || 1) / queuePageSize);
const t = useI18n("widget.mediaTranscoding");
if (!transcodingData) return <WidgetEmptyState />;
const totalQueuePages = Math.ceil((transcodingData.data.queue.totalCount || 1) / queuePageSize);
const isTiny = width < 256;
return (
@@ -7,10 +7,13 @@ import { clientApi } from "@homarr/api/client";
import { formatNumber } from "@homarr/common";
import { useScopedI18n } from "@homarr/translation/client";
import { WidgetEmptyState } from "../../common/empty-state";
import type { WidgetComponentProps } from "../../definition";
export default function MinecraftServerStatusWidget({ options }: WidgetComponentProps<"minecraftServerStatus">) {
const [{ data }] = clientApi.widget.minecraft.getServerStatus.useSuspenseQuery(options);
const { data: result } = clientApi.widget.minecraft.getServerStatus.useQuery(options, {
staleTime: 5 * 60 * 1000,
});
const utils = clientApi.useUtils();
clientApi.widget.minecraft.subscribeServerStatus.useSubscription(options, {
onData(data) {
@@ -22,6 +25,9 @@ export default function MinecraftServerStatusWidget({ options }: WidgetComponent
});
const tStatus = useScopedI18n("widget.minecraftServerStatus.status");
if (!result) return <WidgetEmptyState />;
const { data } = result;
const title = options.title.trim().length > 0 ? options.title : options.domain;
return (
@@ -21,12 +21,12 @@ export default function NetworkControllerNetworkStatusWidget({
options,
integrationIds,
}: WidgetComponentProps<"networkControllerStatus">) {
const [summaries] = clientApi.widget.networkController.summary.useSuspenseQuery(
const { data: summaries = [] } = clientApi.widget.networkController.summary.useQuery(
{
integrationIds,
},
{
refetchOnMount: false,
staleTime: 5 * 60 * 1000,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
retry: false,
@@ -20,12 +20,12 @@ dayjs.extend(duration);
export default function NetworkControllerSummaryWidget({
integrationIds,
}: WidgetComponentProps<"networkControllerSummary">) {
const [summaries] = clientApi.widget.networkController.summary.useSuspenseQuery(
const { data: summaries = [] } = clientApi.widget.networkController.summary.useQuery(
{
integrationIds,
},
{
refetchOnMount: false,
staleTime: 5 * 60 * 1000,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
retry: false,
@@ -12,13 +12,13 @@ import { useScopedI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../definition";
export default function NotificationsWidget({ options, integrationIds }: WidgetComponentProps<"notifications">) {
const [notificationIntegrations] = clientApi.widget.notifications.getNotifications.useSuspenseQuery(
const { data: notificationIntegrations = [] } = clientApi.widget.notifications.getNotifications.useQuery(
{
...options,
integrationIds,
},
{
refetchOnMount: false,
staleTime: 5 * 60 * 1000,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
retry: false,
@@ -8,6 +8,7 @@ import { IconFileDescription, IconFileText, IconInbox, IconTag, IconUsers } from
import { clientApi } from "@homarr/api/client";
import { useScopedI18n } from "@homarr/translation/client";
import { WidgetEmptyState } from "../common/empty-state";
import type { WidgetComponentProps } from "../definition";
import classes from "./component.module.css";
@@ -71,9 +72,12 @@ const heroPartVisibility = {
export default function PaperlessNgxWidget({ integrationIds, options, width }: WidgetComponentProps<"paperlessNgx">) {
const t = useScopedI18n("widget.paperlessNgx");
const [stats] = clientApi.widget.paperlessNgx.getStats.useSuspenseQuery({
integrationId: integrationIds[0] ?? "",
});
const { data: stats } = clientApi.widget.paperlessNgx.getStats.useQuery(
{ integrationId: integrationIds[0] ?? "" },
{ staleTime: 5 * 60 * 1000 },
);
if (!stats) return <WidgetEmptyState />;
const canShowInboxHero = options.showDocumentsInbox && options.showDocumentsTotal;
const showHero = options.showInboxRatio && canShowInboxHero;
+2
View File
@@ -9,6 +9,7 @@ import { createSettings } from "@homarr/settings/creator";
import { reduceWidgetOptionsWithDefaultValues } from ".";
import prefetchForApps from "./app/prefetch";
import prefetchForBookmarks from "./bookmarks/prefetch";
import prefetchForDownloads from "./downloads/prefetch";
import type { Prefetch, WidgetOptionsRecordOf } from "./definition";
import type { inferOptionsFromCreator } from "./options";
@@ -19,6 +20,7 @@ const prefetchCallbacks: Partial<{
}> = {
bookmarks: prefetchForBookmarks,
app: prefetchForApps,
downloads: prefetchForDownloads,
};
export const prefetchForKindAsync = async <TKind extends WidgetKind>(
+7 -2
View File
@@ -81,7 +81,7 @@ export default function ReleasesWidget({ options }: WidgetComponentProps<"releas
);
}, [groupedRepositories]);
const [results] = clientApi.useSuspenseQueries((t) =>
const queryResults = clientApi.useQueries((t) =>
batchedRepositories.flatMap(({ integrationId, repositories }) =>
t.widget.releases.getLatest({
integrationId,
@@ -94,6 +94,11 @@ export default function ReleasesWidget({ options }: WidgetComponentProps<"releas
),
);
const results = useMemo(
() => queryResults.map((q) => q.data).filter((d): d is NonNullable<typeof d> => d != null),
[queryResults],
);
const repositories = useMemo(() => {
const formattedResults = options.repositories
.map((repository) => {
@@ -121,7 +126,7 @@ export default function ReleasesWidget({ options }: WidgetComponentProps<"releas
(repository) =>
"error" in repository || !options.showOnlyHighlighted || repository.isNewRelease || repository.isStaleRelease,
)
.sort((repoA, repoB) => {
.toSorted((repoA, repoB) => {
if ("error" in repoA) return -1;
if ("error" in repoB) return 1;
return repoA.latestReleaseAt > repoB.latestReleaseAt ? -1 : 1;
Loaded 100 of 117 files, more files were not shown because too many files have changed in this diff. Show more