Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bc0680a18c | ||
|
|
3eb642c71b | ||
|
|
f41b544828 | ||
|
|
a7508eab00 | ||
|
|
8d5166ce0a | ||
|
|
3358b6337f | ||
|
|
61d3fd5f08 | ||
|
|
3aac106156 | ||
|
|
9d857377c1 | ||
|
|
923905c529 |
No files matched your search
Binary file not shown.
|
Before Width: | Height: | Size: 32 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 618 KiB |
@@ -23,7 +23,7 @@ Homarr 2.0 is an upgrade, not a brand-new app wearing the same logo. Here is wha
|
||||
|
||||
:::warning Back up before upgrading
|
||||
|
||||
Follow the [v1 to v2 upgrade guide](/docs/getting-started/upgrade-v2) and create a
|
||||
Database migrations run automatically when Homarr starts. Create a
|
||||
[Homarr backup](/docs/management/backup) first. Do not test a prerelease image on your only production volume.
|
||||
Backups are boring. Restores are worse.
|
||||
|
||||
@@ -65,7 +65,7 @@ We have called this the **"ultimate Homarr update"**. Not because work stops, bu
|
||||
|
||||
We threw out the old drag-and-drop system and rebuilt it on [`dnd-kit`](https://dndkit.com/). Moves and eight-direction resizes are transactional: Homarr previews the result, checks collisions and only saves a valid layout. Bad moves roll back instead of wrecking the board. In short: less fighting with your own dashboard.
|
||||
|
||||

|
||||

|
||||
|
||||
### Goodbye Groups and sections, bonjour Containers
|
||||
|
||||
@@ -161,7 +161,7 @@ Board startup work runs in parallel, heavy screens load when opened, and identic
|
||||
## Links
|
||||
|
||||
- Try the [Homarr 2.0 preview](https://app-v2.preview.homarr.dev).
|
||||
- Follow the [v1 to v2 upgrade guide](/docs/getting-started/upgrade-v2).
|
||||
- Follow the [v2 beta setup guide](/docs/getting-started/upgrade-v2).
|
||||
- Follow the [v2 release pull request](https://github.com/homarr-labs/homarr/pull/6545) and [public beta thread](https://github.com/homarr-labs/homarr/issues/6600).
|
||||
- Compare [v1.76.2 with the release/v2 branch](https://github.com/homarr-labs/homarr/compare/v1.76.2...release/v2).
|
||||
- Read the guides for [Boards](/docs/management/boards), [Custom Widgets](/docs/management/custom-widgets), [Assistant](/docs/management/assistant), [MCP](/docs/management/mcp) and [Docker labels](/docs/integrations/docker-labels).
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
title: Upgrade from v1 to v2
|
||||
title: Try the v2 beta
|
||||
sidebar_position: 2
|
||||
tags:
|
||||
- Getting started
|
||||
@@ -8,8 +8,20 @@ tags:
|
||||
- Backup
|
||||
---
|
||||
|
||||
Homarr v2 upgrades the existing v1 database in place. Keep the same data storage, database configuration, and
|
||||
`SECRET_ENCRYPTION_KEY`; only change the Homarr image or package version for the first start.
|
||||
This page covers the temporary Homarr v2 public beta. Try the [live demo](https://app-v2.preview.homarr.dev/)
|
||||
without installing anything, or run the beta image published in the
|
||||
[public beta announcement](https://github.com/homarr-labs/homarr/issues/6600):
|
||||
|
||||
```sh
|
||||
docker pull ghcr.io/homarr-labs/homarr-test:v2
|
||||
```
|
||||
|
||||
For a fresh beta installation, follow the [Docker installation guide](/docs/getting-started/installation/docker),
|
||||
use `ghcr.io/homarr-labs/homarr-test:v2` as the image, and use a separate `/appdata` volume.
|
||||
Set `WORKSHOP_API_URL=https://v2.preview.homarr.dev` to use the preview Workshop.
|
||||
|
||||
Homarr runs database migrations automatically at startup; no manual database migration command is needed.
|
||||
Once v2 is released, existing installations can use their normal update process. The beta image below is temporary.
|
||||
|
||||
:::danger Back up before starting v2
|
||||
|
||||
@@ -18,21 +30,23 @@ before starting v2. See [Backup and restore](/docs/management/backup) for detail
|
||||
|
||||
:::
|
||||
|
||||
## Upgrade
|
||||
## Try the beta with existing v1 data
|
||||
|
||||
1. Pin the Homarr image to `ghcr.io/homarr-labs/homarr:v2.0.0` for the first v2 start.
|
||||
1. Change the Homarr image to `ghcr.io/homarr-labs/homarr-test:v2`.
|
||||
2. Keep the existing `/appdata` mount, database variables, Redis configuration, and `SECRET_ENCRYPTION_KEY` unchanged.
|
||||
3. Start one Homarr replica and follow its logs. Startup must reach `Migration complete`; Homarr aborts startup when a
|
||||
database migration fails.
|
||||
4. Open Homarr and sign in. Confirm **Management → About** reports v2 before starting additional replicas.
|
||||
5. After validation, keep the version pinned or return to your normal stable release policy.
|
||||
5. Keep using the beta image for beta updates. To return to v1, restore the pre-beta backup as described below.
|
||||
|
||||
For Docker Compose, the change is limited to the image tag before the usual update:
|
||||
Keep your other existing Compose settings, including environment variables. For Docker Compose, update the image and preview Workshop URL before the usual update:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
homarr:
|
||||
image: ghcr.io/homarr-labs/homarr:v2.0.0
|
||||
image: ghcr.io/homarr-labs/homarr-test:v2
|
||||
environment:
|
||||
WORKSHOP_API_URL: https://v2.preview.homarr.dev
|
||||
```
|
||||
|
||||
```sh
|
||||
|
||||
@@ -27,6 +27,12 @@ session cookie.
|
||||
API keys do not expire automatically and act as the user that created them. Every request uses that user's group and
|
||||
resource permissions.
|
||||
|
||||
## Invitation dates
|
||||
|
||||
`POST /api/invites` and the tRPC `invite.createInvite` procedure accept `expirationDate` as an ISO 8601 timestamp with a
|
||||
timezone, for example `2026-12-01T18:00:00Z`. Homarr validates the timestamp and stores it as a date. tRPC callers should
|
||||
send the ISO string rather than a JavaScript `Date` object.
|
||||
|
||||
## Health probes
|
||||
|
||||
The liveness endpoint does not require authentication:
|
||||
|
||||
@@ -36,6 +36,11 @@ current Base layout.
|
||||
A **Container** groups apps, widgets, and nested Containers. It can have a label, collapse control, app-opening action,
|
||||
border color, and CSS classes.
|
||||
|
||||
Items can occupy the outermost rows and columns of a Container. The drop preview shows the position where the item
|
||||
will remain when released.
|
||||
Dragging an item onto another item of the same size swaps their positions. Displaced items snap directly to their
|
||||
previewed positions without a movement animation and stay there when you release the pointer.
|
||||
|
||||
A **rail** reserves one to three columns at the left or right of a non-Mobile layout. Rails stay fixed while the page
|
||||
scrolls and store their contents separately for each layout.
|
||||
|
||||
|
||||
@@ -8,18 +8,17 @@ tags:
|
||||
|
||||
# Model Context Protocol (MCP)
|
||||
|
||||
Homarr exposes a stateless [Model Context Protocol](https://modelcontextprotocol.io) server at `/api/mcp`. It maps the
|
||||
current Homarr API to tools for compatible AI clients.
|
||||
Connect an AI client to Homarr to manage boards, apps, integrations, and other services through MCP tools.
|
||||
|
||||
Homarr uses MCP v2 (protocol `2026-07-28`) at `/api/mcp`, with compatibility for 2025 Streamable HTTP clients.
|
||||
Requests are stateless: each request authenticates independently, without an MCP session ID.
|
||||
|
||||

|
||||
|
||||
The endpoint implements MCP `2026-07-28` and accepts 2025 Streamable HTTP clients through a compatibility path. The old
|
||||
`/api/mcp/mcp` route remains as an alias; use `/api/mcp` for new clients.
|
||||
## Connect a client
|
||||
|
||||
## Configure a client
|
||||
|
||||
Create an API key under **Management → Tools → API → Authentication**, then add the endpoint and header to the client's
|
||||
MCP configuration:
|
||||
Create an API key under **Management → Tools → API → Authentication**. Add this configuration to your client,
|
||||
replacing the URL and API key:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -34,7 +33,10 @@ MCP configuration:
|
||||
}
|
||||
```
|
||||
|
||||
Clients without Streamable HTTP support can use `mcp-remote` over STDIO:
|
||||
Use the complete key, including the ID, dot, and token. Keep it private and use HTTPS for remote connections.
|
||||
The **AI / MCP** tab on Homarr's API page provides your endpoint, configuration, and available tools.
|
||||
|
||||
For clients that only support STDIO, use `mcp-remote`:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -47,26 +49,30 @@ Clients without Streamable HTTP support can use `mcp-remote` over STDIO:
|
||||
}
|
||||
```
|
||||
|
||||
The **AI / MCP** tab on Homarr's API page shows the current endpoint, configuration, and generated tool catalog. Filter
|
||||
the catalog by namespace, tool name, or description; matching categories expand automatically.
|
||||
### OAuth
|
||||
|
||||
## Authentication and permissions
|
||||
Clients with OAuth support can authorize through OAuth 2.1 with PKCE instead of an API key.
|
||||
Homarr publishes discovery metadata under `/.well-known/`.
|
||||
|
||||
MCP supports API keys and OAuth 2.1 with PKCE. API keys are simpler for direct client configuration; OAuth is available
|
||||
for clients that support dynamic authorization.
|
||||
If your reverse proxy does not preserve the public host and protocol, set `BASE_URL` to your public origin,
|
||||
such as `https://homarr.example.com`, without a path.
|
||||
|
||||
When Homarr is behind a reverse proxy that does not preserve the public host and protocol, set `BASE_URL` to the exact
|
||||
public origin, for example `https://homarr.example.com`. Do not include a path. OAuth clients discover the authorization
|
||||
server and protected resource through Homarr's `/.well-known/` routes.
|
||||
## Permissions and tools
|
||||
|
||||
Tools use the permissions of the API key owner or authorized user. Creating a key does not grant additional access.
|
||||
Custom Widget and Workshop authoring tools, prompts, and resources are visible only to administrators.
|
||||
Tools use the permissions of the API key owner or OAuth user. Queries read data; mutations change it.
|
||||
Inspect the **AI / MCP** tab or your client's tool list for available actions.
|
||||
|
||||
Use HTTPS when the endpoint crosses an untrusted network.
|
||||
Custom Widget and Workshop authoring tools, prompts, and resources require administrator access.
|
||||
For Custom Widgets, install the official skill and follow the preview-and-evidence workflow described in
|
||||
[Connect an agent](./custom-widgets/agent-authoring). Secret values are configured in Homarr and are never returned
|
||||
through MCP.
|
||||
|
||||
## Verify the endpoint
|
||||
The built-in [Assistant](./assistant) uses the same tool catalog with the current Homarr session and asks for approval
|
||||
before mutations by default.
|
||||
|
||||
The 2025 compatibility path accepts a compact `tools/list` request:
|
||||
## Verify the connection
|
||||
|
||||
This request lists tools through the older-client compatibility path. It checks authentication and tool discovery:
|
||||
|
||||
```sh
|
||||
curl -X POST \
|
||||
@@ -77,17 +83,14 @@ curl -X POST \
|
||||
https://homarr.example.com/api/mcp
|
||||
```
|
||||
|
||||
The removed HTTP+SSE `/sse` and `/message` transports return `410 Gone`.
|
||||
A successful discovery or initialization response alone does not verify that tools can be listed.
|
||||
|
||||
## Tools and resources
|
||||
## Troubleshooting
|
||||
|
||||
The generated catalog includes available board, app, integration, Docker, Kubernetes, media, DNS, download, and other
|
||||
Homarr procedures. Queries are read-only; mutations change state. The exact catalog depends on the release and current
|
||||
user permissions, so inspect `tools/list` or the table in Homarr instead of relying on a static list.
|
||||
|
||||
For Custom Widgets, install the official skill and follow the preview-and-evidence workflow described in
|
||||
[Connect an agent](./custom-widgets/agent-authoring). Secret values are configured in Homarr and are never returned
|
||||
through MCP.
|
||||
|
||||
The built-in [Assistant](./assistant) uses the same tool catalog with the current Homarr session and asks for approval
|
||||
before mutations by default.
|
||||
- **`invalid_token`**: copy the complete API key in `<id>.<token>` format.
|
||||
- **Missing tools**: check the user's permissions. Invalid tool schemas and duplicate names are omitted; check server
|
||||
logs for `MCP tool omitted from catalog` and report the diagnostic with your Homarr version. Exclude API keys.
|
||||
- **`Date cannot be represented in JSON Schema`**: update Homarr. Older versions could fail to list all tools because
|
||||
of an unsupported date schema.
|
||||
- **`410 Gone` on `/sse` or `/message`**: use `/api/mcp` with a Streamable HTTP client. The old `/api/mcp/mcp` URL remains
|
||||
an alias.
|
||||
@@ -15,10 +15,12 @@ import { bookmarksWidget } from ".";
|
||||
|
||||
The Bookmarks widget keeps direct URLs and Homarr Apps in one ordered list. URLs do not require an App record; Homarr
|
||||
uses the site's favicon when available. Choose adaptive, compact-grid, or other layouts from the widget configuration.
|
||||
Adaptive, vertical, and grid layouts keep larger cards when the configured bookmarks fit, then reduce card height and
|
||||
spacing to show as many bookmarks as possible before scrolling. Horizontal uses a compact, centered scrolling row with
|
||||
narrower cards in one-row widgets. Horizontal, compact-grid, and icon-only layouts keep their gaps tight. Compact grid
|
||||
also preserves its configured columns when the dashboard is scaled.
|
||||
Vertical uses consistent compact rows and keeps bookmark names visible whenever the widget is wide enough, regardless
|
||||
of how many bookmarks are configured. Adaptive and grid layouts reduce card height and spacing to show as many
|
||||
bookmarks as possible before scrolling. Horizontal uses a compact, centered scrolling row with narrower cards in
|
||||
one-row widgets. Horizontal, vertical, compact-grid, and icon-only layouts keep their gaps tight. Compact grid also
|
||||
preserves its configured columns when the dashboard is scaled. Bookmark cards, icons, text, and gaps keep the same
|
||||
visual density when a low-column dashboard is enlarged to fill the screen.
|
||||
|
||||
### Screenshots
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ export const bookmarksWidget: WidgetDefinition = {
|
||||
{
|
||||
name: "Layout",
|
||||
description:
|
||||
"How bookmarks use the available widget space. Layouts stay compact and reduce card size when needed.",
|
||||
"How bookmarks use the available widget space. Vertical keeps compact rows and stable title visibility.",
|
||||
values: { type: "select", options: ["Adaptive", "Vertical", "Horizontal", "Grid", "Compact grid", "Icons"] },
|
||||
defaultValue: "Adaptive",
|
||||
},
|
||||
|
||||
+3
-1
@@ -2572,7 +2572,9 @@
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"expirationDate": {
|
||||
"type": "string"
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { z } from "zod/v4";
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { useUpdateBoard } from "@homarr/boards/updater";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import { env } from "@homarr/common/env";
|
||||
import { useZodForm } from "@homarr/form";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useSettings } from "@homarr/settings";
|
||||
@@ -107,7 +108,7 @@ export const BoardSettingsForm = ({ board, permissions, hasFullAccess, hideVisib
|
||||
const initialValuesRef = useRef(buildInitialValues(board));
|
||||
const lastSavedRef = useRef({ pageTitle: board.pageTitle, logoImageUrl: board.logoImageUrl });
|
||||
|
||||
useUnsavedChangesGuard(form.isDirty());
|
||||
useUnsavedChangesGuard(form.isDirty(), { guardBeforeUnload: env.NODE_ENV !== "development" });
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
|
||||
@@ -8,6 +8,7 @@ import { z } from "zod/v4";
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import { env } from "@homarr/common/env";
|
||||
import { colorSchemes } from "@homarr/definitions";
|
||||
import { useZodForm } from "@homarr/form";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
@@ -80,7 +81,7 @@ export const SettingsForm = ({ initialData, selectableBoards, selectableSearchEn
|
||||
initialValues,
|
||||
});
|
||||
|
||||
useUnsavedChangesGuard(form.isDirty());
|
||||
useUnsavedChangesGuard(form.isDirty(), { guardBeforeUnload: env.NODE_ENV !== "development" });
|
||||
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
|
||||
+2
-1
@@ -27,6 +27,7 @@ import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import type { BoardPreviewData } from "@homarr/boards/layout-preview";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import { env } from "@homarr/common/env";
|
||||
import { useZodForm } from "@homarr/form";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
@@ -125,7 +126,7 @@ export const UserGeneralSettingsForm = ({
|
||||
initialValues,
|
||||
});
|
||||
|
||||
useUnsavedChangesGuard(form.isDirty());
|
||||
useUnsavedChangesGuard(form.isDirty(), { guardBeforeUnload: env.NODE_ENV !== "development" });
|
||||
|
||||
const weekDays = useMemo(() => dayjs.weekdays(false), []);
|
||||
|
||||
|
||||
+1
-1
@@ -45,7 +45,7 @@ export const InviteCreateForm = ({ onClose }: InviteCreateFormProps) => {
|
||||
onSubmit={form.onSubmit((values) => {
|
||||
mutate(
|
||||
{
|
||||
expirationDate: new Date(values.expirationDate),
|
||||
expirationDate: new Date(values.expirationDate).toISOString(),
|
||||
},
|
||||
{
|
||||
onSuccess: (result) => {
|
||||
|
||||
@@ -7,7 +7,7 @@ import { createUIMessageStream, createUIMessageStreamResponse, jsonSchema, stepC
|
||||
import { cookies } from "next/headers";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { createTRPCContext, mcpRouter } from "@homarr/api/mcp";
|
||||
import { callMcpTool, createTRPCContext, mcpRouter } from "@homarr/api/mcp";
|
||||
import {
|
||||
createAssistantGenerationAccessToken,
|
||||
getAssistantRequestContextEntitiesAsync,
|
||||
@@ -547,19 +547,7 @@ export async function POST(request: Request) {
|
||||
}
|
||||
if (contextRequestKey !== null) loadedCustomWidgetContextRequests.add(contextRequestKey);
|
||||
try {
|
||||
const procedure = mcpTool.pathInRouter.reduce<unknown>(
|
||||
(current, segment) =>
|
||||
(typeof current === "object" || typeof current === "function") && current !== null
|
||||
? (current as Record<string, unknown>)[segment]
|
||||
: undefined,
|
||||
caller,
|
||||
);
|
||||
if (typeof procedure !== "function") {
|
||||
throw new Error("Procedure not callable");
|
||||
}
|
||||
const result = await (procedure as (value: unknown) => Promise<unknown>)(
|
||||
executionInput && Object.keys(executionInput as object).length > 0 ? executionInput : undefined,
|
||||
);
|
||||
const result = await callMcpTool(caller, mcpTool, executionInput);
|
||||
customWidgetDiscoveryPhase.observe(mcpTool.name, result);
|
||||
return toAssistantToolOutput(result, {
|
||||
maxCharacters: getAssistantToolOutputMaxCharacters(mcpTool.name),
|
||||
|
||||
@@ -2,8 +2,11 @@
|
||||
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import type * as CatalogModule from "../_extract-tools";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
authenticate: vi.fn(),
|
||||
extractCatalog: vi.fn(),
|
||||
ipAddress: vi.fn(() => "127.0.0.1"),
|
||||
toolProcedure: vi.fn(),
|
||||
loggerWarn: vi.fn(),
|
||||
@@ -14,7 +17,9 @@ const mocks = vi.hoisted(() => ({
|
||||
}));
|
||||
|
||||
vi.mock("next/server", () => ({ userAgent: () => ({ ua: "MCP route test" }) }));
|
||||
vi.mock("@homarr/api/mcp", () => ({
|
||||
vi.mock("@homarr/api/mcp", async () => ({
|
||||
callMcpTool: (await import("../../../../../../../packages/api/src/mcp-tools")).callMcpTool,
|
||||
extractMcpToolsFromProcedures: mocks.extractCatalog,
|
||||
createTRPCContext: vi.fn(() => ({})),
|
||||
mcpRouter: { createCaller: vi.fn(() => ({ board: { getAllBoards: mocks.toolProcedure } })) },
|
||||
}));
|
||||
@@ -54,6 +59,7 @@ vi.mock("../_extract-tools", () => ({
|
||||
description: "List boards",
|
||||
type: "query",
|
||||
pathInRouter: ["board", "getAllBoards"],
|
||||
inputMode: "none",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
},
|
||||
{
|
||||
@@ -61,6 +67,7 @@ vi.mock("../_extract-tools", () => ({
|
||||
description: "List Custom Widgets",
|
||||
type: "query",
|
||||
pathInRouter: ["customWidget", "list"],
|
||||
inputMode: "none",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
},
|
||||
{
|
||||
@@ -68,6 +75,7 @@ vi.mock("../_extract-tools", () => ({
|
||||
description: "Search Workshop",
|
||||
type: "query",
|
||||
pathInRouter: ["customWidget", "workshopSearch"],
|
||||
inputMode: "none",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
},
|
||||
],
|
||||
@@ -305,3 +313,21 @@ describe("MCP protocol errors", () => {
|
||||
expect(metadata).not.toHaveProperty("message");
|
||||
});
|
||||
});
|
||||
|
||||
test("logs catalog omissions only when the shared catalog is first built", async () => {
|
||||
const diagnostic = { name: "broken", path: "broken", reason: "invalid_schema" };
|
||||
const tools = [{ name: "valid" }];
|
||||
mocks.extractCatalog.mockReturnValue({ tools, diagnostics: [diagnostic] });
|
||||
const { extractMcpTools } = await vi.importActual<typeof CatalogModule>("../_extract-tools");
|
||||
expect(extractMcpTools()).toBe(tools);
|
||||
expect(extractMcpTools()).toBe(tools);
|
||||
expect(mocks.extractCatalog).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.loggerWarn).toHaveBeenCalledExactlyOnceWith("MCP tool omitted from catalog", diagnostic);
|
||||
});
|
||||
|
||||
test("rejects unauthenticated tool calls before execution", async () => {
|
||||
mocks.authenticate.mockResolvedValue(null);
|
||||
const { response } = await callMcp("tools/call", { name: "board_getAllBoards", arguments: {} });
|
||||
expect(response.status).toBe(401);
|
||||
expect(mocks.toolProcedure).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -1,11 +1,12 @@
|
||||
import { extractMcpToolsFromProcedures, mcpRouter } from "@homarr/api/mcp";
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
|
||||
const logger = createLogger({ module: "mcp-catalog" });
|
||||
let cache: ReturnType<typeof extractMcpToolsFromProcedures> | null = null;
|
||||
|
||||
export function extractMcpTools() {
|
||||
if (cache) return cache;
|
||||
|
||||
if (cache) return cache.tools;
|
||||
cache = extractMcpToolsFromProcedures(mcpRouter);
|
||||
|
||||
return cache;
|
||||
for (const diagnostic of cache.diagnostics) logger.warn("MCP tool omitted from catalog", diagnostic);
|
||||
return cache.tools;
|
||||
}
|
||||
@@ -1,12 +1,18 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import { initTRPC, TRPCError } from "@trpc/server";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import type { McpTool } from "@homarr/api/mcp";
|
||||
import { appManageSchema } from "@homarr/validation/app";
|
||||
import { extractMcpToolsFromProcedures } from "@homarr/api/mcp";
|
||||
import type { McpMeta } from "../../../../../../packages/api/src/mcp-tools";
|
||||
|
||||
import { createMcpProtocolHandler } from "./_protocol";
|
||||
|
||||
vi.mock("@homarr/api/mcp", async () => import("../../../../../../packages/api/src/mcp-tools"));
|
||||
|
||||
const modernMetadata = {
|
||||
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
|
||||
"io.modelcontextprotocol/clientInfo": { name: "homarr-test", version: "1.0.0" },
|
||||
@@ -52,7 +58,7 @@ const echoTool: McpTool = {
|
||||
description: "Echo a message",
|
||||
type: "query",
|
||||
pathInRouter: ["echo"],
|
||||
inputValidator: echoValidator,
|
||||
inputMode: "object",
|
||||
inputSchema: z.toJSONSchema(echoValidator),
|
||||
};
|
||||
|
||||
@@ -163,3 +169,197 @@ describe("MCP v2 protocol", () => {
|
||||
expect((await parseResponse(listResponse)).result.tools).toEqual([expect.objectContaining({ name: "echo" })]);
|
||||
});
|
||||
});
|
||||
|
||||
describe.each([false, true])("MCP argument dispatch (modern=%s)", (modern) => {
|
||||
const trpc = initTRPC.context<{ user: string }>().meta<McpMeta>().create();
|
||||
const exposed = trpc.procedure.meta({ mcp: { enabled: true } });
|
||||
|
||||
const call = async (
|
||||
handler: ReturnType<typeof createMcpProtocolHandler>,
|
||||
name: string,
|
||||
argumentsValue: Record<string, unknown>,
|
||||
) => {
|
||||
let request = createRequest("tools/call", { name, arguments: argumentsValue }, name);
|
||||
if (!modern) {
|
||||
request = new Request("http://homarr.test/api/mcp", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Accept: "application/json, text/event-stream" },
|
||||
body: JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
id: 1,
|
||||
method: "tools/call",
|
||||
params: { name, arguments: argumentsValue },
|
||||
}),
|
||||
});
|
||||
}
|
||||
const response = await handler.fetch(request);
|
||||
expect(response.headers.has("Mcp-Session-Id")).toBe(false);
|
||||
return (await parseResponse(response)).result;
|
||||
};
|
||||
|
||||
test("transforms once and does not coerce, insert defaults, or remove properties before tRPC", async () => {
|
||||
const transform = vi.fn((value: string) => `${value}!`);
|
||||
const execution = vi.fn((input: unknown) => input);
|
||||
const router = trpc.router({
|
||||
update: exposed
|
||||
.input(
|
||||
z.looseObject({
|
||||
name: z.string().transform(transform),
|
||||
limit: z.number().default(7),
|
||||
}),
|
||||
)
|
||||
.mutation(({ input }) => execution(input)),
|
||||
});
|
||||
const catalog = extractMcpToolsFromProcedures(router);
|
||||
expect(catalog.diagnostics).toEqual([]);
|
||||
const realCaller = router.createCaller({ user: "one" });
|
||||
const dispatch = vi.fn(realCaller.update);
|
||||
const handler = createMcpProtocolHandler({
|
||||
caller: { update: dispatch },
|
||||
tools: catalog.tools,
|
||||
version: "test",
|
||||
instructions: "test",
|
||||
formatToolError: () => "Invalid input",
|
||||
});
|
||||
const argumentsValue = { name: "hello", extra: "preserved" };
|
||||
const result = await call(handler, "update", argumentsValue);
|
||||
expect(result.isError).not.toBe(true);
|
||||
expect(dispatch).toHaveBeenCalledWith(argumentsValue);
|
||||
expect(transform).toHaveBeenCalledTimes(1);
|
||||
expect(execution).toHaveBeenCalledWith({ name: "hello!", limit: 7, extra: "preserved" });
|
||||
await call(handler, "update", { name: "hello", limit: "7" });
|
||||
expect(dispatch).toHaveBeenCalledTimes(1);
|
||||
expect(execution).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("retains overlapping chained constraints, refinements, and empty object defaults", async () => {
|
||||
const execution = vi.fn((input: unknown) => input);
|
||||
const router = trpc.router({
|
||||
bounded: exposed
|
||||
.input(z.object({ count: z.number().min(2) }))
|
||||
.input(z.object({ count: z.number().max(5) }))
|
||||
.mutation(({ input }) => execution(input)),
|
||||
refined: exposed
|
||||
.input(z.object({ value: z.string().refine((value) => value !== "blocked") }))
|
||||
.mutation(({ input }) => execution(input)),
|
||||
defaults: exposed.input(z.object({ count: z.number().default(3) })).query(({ input }) => input),
|
||||
none: exposed.query(({ input }) => ({ absent: input === undefined })),
|
||||
});
|
||||
const catalog = extractMcpToolsFromProcedures(router);
|
||||
expect(catalog.diagnostics).toEqual([]);
|
||||
const handler = createMcpProtocolHandler({
|
||||
caller: router.createCaller({ user: "one" }),
|
||||
tools: catalog.tools,
|
||||
version: "test",
|
||||
instructions: "test",
|
||||
formatToolError: () => "Invalid input",
|
||||
});
|
||||
for (const count of [1, 6]) expect((await call(handler, "bounded", { count })).isError).toBe(true);
|
||||
expect((await call(handler, "refined", { value: "blocked" })).isError).toBe(true);
|
||||
expect(execution).not.toHaveBeenCalled();
|
||||
expect((await call(handler, "bounded", { count: 3 })).isError).not.toBe(true);
|
||||
expect(execution).toHaveBeenCalledWith({ count: 3 });
|
||||
expect((await call(handler, "defaults", {})).content).toEqual([{ type: "text", text: '{"count":3}' }]);
|
||||
expect((await call(handler, "none", {})).content).toEqual([{ type: "text", text: '{"absent":true}' }]);
|
||||
});
|
||||
|
||||
test("accepts app inputs that tRPC normalizes before checking constraints", async () => {
|
||||
const mutation = vi.fn((input: unknown) => input);
|
||||
const router = trpc.router({ create: exposed.input(appManageSchema).mutation(({ input }) => mutation(input)) });
|
||||
const { tools, diagnostics } = extractMcpToolsFromProcedures(router);
|
||||
expect(diagnostics).toEqual([]);
|
||||
const handler = createMcpProtocolHandler({
|
||||
caller: router.createCaller({ user: "admin" }),
|
||||
tools,
|
||||
version: "test",
|
||||
instructions: "test",
|
||||
formatToolError: () => "Invalid input",
|
||||
});
|
||||
const argumentsValue = {
|
||||
name: `${" ".repeat(64)}App `,
|
||||
description: " description ",
|
||||
iconUrl: " icon ",
|
||||
href: null,
|
||||
pingUrl: " https://example.com ",
|
||||
};
|
||||
expect((await call(handler, "create", argumentsValue)).isError).not.toBe(true);
|
||||
expect(mutation).toHaveBeenCalledExactlyOnceWith({
|
||||
name: "App",
|
||||
description: "description",
|
||||
iconUrl: "icon",
|
||||
href: null,
|
||||
pingUrl: "https://example.com",
|
||||
});
|
||||
expect((await call(handler, "create", { ...argumentsValue, pingUrl: " javascript:alert(1) " })).isError).toBe(true);
|
||||
expect(mutation).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("accepts Zod ISO timestamps without seconds while rejecting invalid dates", async () => {
|
||||
const mutation = vi.fn((input: unknown) => input);
|
||||
const router = trpc.router({
|
||||
invite: exposed
|
||||
.input(z.object({ expirationDate: z.iso.datetime({ offset: true }) }))
|
||||
.mutation(({ input }) => mutation(input)),
|
||||
});
|
||||
const { tools, diagnostics } = extractMcpToolsFromProcedures(router);
|
||||
expect(diagnostics).toEqual([]);
|
||||
const handler = createMcpProtocolHandler({
|
||||
caller: router.createCaller({ user: "admin" }),
|
||||
tools,
|
||||
version: "test",
|
||||
instructions: "test",
|
||||
formatToolError: () => "Invalid input",
|
||||
});
|
||||
for (const expirationDate of ["2026-12-01T18:00Z", "2026-12-01T19:00+01:00"]) {
|
||||
expect((await call(handler, "invite", { expirationDate })).isError).not.toBe(true);
|
||||
expect(mutation).toHaveBeenLastCalledWith({ expirationDate });
|
||||
}
|
||||
for (const expirationDate of ["2026-02-30T18:00Z", "not-a-date"]) {
|
||||
expect((await call(handler, "invite", { expirationDate })).isError).toBe(true);
|
||||
}
|
||||
expect(mutation).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
test("enforces tRPC permissions before executing mutations", async () => {
|
||||
const mutation = vi.fn(() => "changed");
|
||||
const router = trpc.router({
|
||||
restricted: exposed
|
||||
.use(({ ctx, next }) => {
|
||||
if (ctx.user !== "admin") throw new TRPCError({ code: "FORBIDDEN" });
|
||||
return next();
|
||||
})
|
||||
.input(z.object({}))
|
||||
.mutation(mutation),
|
||||
});
|
||||
const { tools, diagnostics } = extractMcpToolsFromProcedures(router);
|
||||
expect(diagnostics).toEqual([]);
|
||||
const handler = createMcpProtocolHandler({
|
||||
caller: router.createCaller({ user: "guest" }),
|
||||
tools,
|
||||
version: "test",
|
||||
instructions: "test",
|
||||
formatToolError: () => "Forbidden",
|
||||
});
|
||||
expect((await call(handler, "restricted", {})).isError).toBe(true);
|
||||
expect(mutation).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("isolates authenticated callers when reusing catalog schemas", async () => {
|
||||
const router = trpc.router({ who: exposed.query(({ ctx }) => ctx.user) });
|
||||
const { tools } = extractMcpToolsFromProcedures(router);
|
||||
const handlers = ["alice", "bob"].map((user) =>
|
||||
createMcpProtocolHandler({
|
||||
caller: router.createCaller({ user }),
|
||||
tools,
|
||||
version: "test",
|
||||
instructions: "test",
|
||||
formatToolError: () => "Failed",
|
||||
}),
|
||||
);
|
||||
const results = await Promise.all(handlers.map((handler) => call(handler, "who", {})));
|
||||
expect(results.map((result) => result.content)).toEqual([
|
||||
[{ type: "text", text: '"alice"' }],
|
||||
[{ type: "text", text: '"bob"' }],
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createMcpHandler, McpServer } from "@modelcontextprotocol/server";
|
||||
import { createMcpHandler, fromJsonSchema, McpServer } from "@modelcontextprotocol/server";
|
||||
|
||||
import { callMcpTool } from "@homarr/api/mcp";
|
||||
import type { McpTool } from "@homarr/api/mcp";
|
||||
|
||||
import { toAssistantToolOutput } from "../assistant/chat/assistant-tool-output";
|
||||
@@ -15,15 +16,6 @@ interface CreateMcpProtocolHandlerOptions {
|
||||
onProtocolError?: (error: Error) => void;
|
||||
}
|
||||
|
||||
const resolveProcedure = (caller: unknown, path: string[]) =>
|
||||
path.reduce<unknown>(
|
||||
(current, segment) =>
|
||||
(typeof current === "object" || typeof current === "function") && current !== null
|
||||
? (current as Record<string, unknown>)[segment]
|
||||
: undefined,
|
||||
caller,
|
||||
);
|
||||
|
||||
export const createMcpProtocolHandler = ({
|
||||
caller,
|
||||
tools,
|
||||
@@ -52,20 +44,11 @@ export const createMcpProtocolHandler = ({
|
||||
tool.name,
|
||||
{
|
||||
description: tool.description,
|
||||
inputSchema: tool.inputValidator,
|
||||
inputSchema: fromJsonSchema(tool.inputSchema as Parameters<typeof fromJsonSchema>[0]),
|
||||
},
|
||||
async (args) => {
|
||||
try {
|
||||
const procedure = resolveProcedure(caller, tool.pathInRouter);
|
||||
if (typeof procedure !== "function") {
|
||||
return {
|
||||
content: [{ type: "text" as const, text: `Tool "${tool.name}" is not callable` }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
const input = Object.keys(args).length > 0 ? args : undefined;
|
||||
const result = await (procedure as (value: unknown) => Promise<unknown>)(input);
|
||||
const result = await callMcpTool(caller, tool, args);
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
.board-grid-editor {
|
||||
position: absolute;
|
||||
inset: 0 auto auto 0;
|
||||
inset: 0 0 auto;
|
||||
margin: 0 auto;
|
||||
overflow: visible;
|
||||
border-radius: var(--mantine-radius-md);
|
||||
pointer-events: none;
|
||||
zoom: var(--board-grid-content-scale, 1);
|
||||
}
|
||||
|
||||
.board-grid-entry {
|
||||
@@ -20,14 +22,7 @@
|
||||
width: var(--board-grid-preview-width) !important;
|
||||
height: var(--board-grid-preview-height) !important;
|
||||
translate: var(--board-grid-preview-x) var(--board-grid-preview-y);
|
||||
will-change: translate;
|
||||
}
|
||||
|
||||
.board-grid-entry[data-grid-preview="true"]:not([data-dnd-active="true"]) {
|
||||
transition:
|
||||
translate 100ms ease,
|
||||
width 100ms ease,
|
||||
height 100ms ease;
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.board-grid-entry[data-dnd-active="true"],
|
||||
@@ -109,8 +104,8 @@
|
||||
zoom: var(--board-grid-overlay-content-zoom, 1);
|
||||
}
|
||||
|
||||
body[data-board-grid-interacting] .board-grid-entry[data-dnd-active="true"],
|
||||
body[data-board-grid-interacting] .board-grid-entry[data-dnd-drag-source="true"],
|
||||
/* Displaced entries must snap with the preview too; animating their offsets can trail a new swap target. */
|
||||
body[data-board-grid-interacting] .board-grid-entry,
|
||||
body[data-board-grid-interacting] .board-grid-placeholder {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
@@ -415,7 +415,11 @@ export const BoardGridEditorProvider = ({ children }: PropsWithChildren) => {
|
||||
sectionId: grid.id,
|
||||
placements: grid.placements,
|
||||
}));
|
||||
commitSectionGrids(snapshots);
|
||||
const hasChanges = commitSectionGrids(snapshots);
|
||||
if (!hasChanges) {
|
||||
clearInteraction();
|
||||
return;
|
||||
}
|
||||
|
||||
const placement = state.grids
|
||||
.flatMap((grid) => grid.placements)
|
||||
@@ -429,7 +433,9 @@ export const BoardGridEditorProvider = ({ children }: PropsWithChildren) => {
|
||||
)} ${placement.h}`,
|
||||
);
|
||||
}
|
||||
clearInteraction();
|
||||
// Cache subscribers receive the committed board asynchronously. Keep the preview until
|
||||
// the board-change layout effect clears it, so displaced items never flash at their old positions.
|
||||
activeRef.current = null;
|
||||
},
|
||||
[announce, clearInteraction, commitSectionGrids, currentLayoutId, t],
|
||||
);
|
||||
|
||||
@@ -46,7 +46,6 @@
|
||||
|
||||
.editorGrid {
|
||||
position: absolute;
|
||||
inset: 0 auto auto 0;
|
||||
}
|
||||
|
||||
.editorPortalHost {
|
||||
@@ -63,6 +62,11 @@
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
/* Containers provide their own inner item gap, so their card must keep the full grid aspect ratio. */
|
||||
.staticItem[data-type="section"] > .contentMount {
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
.staticItem[data-type="item"] > .contentMount {
|
||||
inset: calc(10px * var(--board-canvas-inverse-scale, 1));
|
||||
}
|
||||
|
||||
@@ -143,40 +143,15 @@ export const SectionGrid = ({
|
||||
const isScrollableContainer = section.kind === "container" && section.options.scrollable;
|
||||
const viewportRowCount =
|
||||
viewportRowCountOverride ?? (isScrollableContainer ? Math.max(requestedRowCount, 1) : rowCount);
|
||||
// A container's own visible card is inset from its allocated board cell by the board's
|
||||
// standard per-item gap (see the base, non-container `.staticItem[data-type="item"] >
|
||||
// .contentMount` rule in section-grid.module.css - a container is placed as an "item" like
|
||||
// any widget, so it gets that same gap). A widget's content just fills whatever size its
|
||||
// card ends up being, but this SectionGrid instead computes its own fixed pixel size from
|
||||
// the same column/row counts used to allocate the *outer*, uninset cell - so without
|
||||
// subtracting that gap back out here, a container's inner grid renders larger than its own
|
||||
// card and visually spills past its right/bottom edges. 10 must match that CSS rule's inset.
|
||||
const effectiveCanvasScale = Number.isFinite(canvasScale) && canvasScale > 0 ? canvasScale : 1;
|
||||
const outerCardInset = section.kind === "container" ? (2 * 10) / effectiveCanvasScale : 0;
|
||||
// A collapsible container's toggle bar (see the `containerToggle` Button in
|
||||
// container-section.tsx) is an absolutely positioned overlay sitting on top of this grid.
|
||||
// Its height comes from a Mantine size prop, which - like spacing/font-size - is compensated
|
||||
// by --mantine-scale to render at a constant physical size regardless of board zoom (see
|
||||
// scaled-board-canvas.module.css). That makes it behave like the outerCardInset gap above,
|
||||
// not like the grid's own un-compensated logical-pixel math, so it needs the same
|
||||
// effectiveCanvasScale conversion - a plain, unconverted subtraction would under-reserve on
|
||||
// a zoomed-out board and leave the header overlapping the content. Without reserving it at
|
||||
// all, the header bar covers the first row of the container's content instead of sitting
|
||||
// above it. Only the vertical axis is affected - the toggle spans the full width already.
|
||||
// The collapsible label keeps a fixed physical height while the grid uses logical canvas pixels.
|
||||
// Convert that height so the label reserves the same space at every board scale.
|
||||
const collapsibleHeaderInset =
|
||||
section.kind === "container" && section.options.collapsible ? CONTAINER_HEADER_HEIGHT / effectiveCanvasScale : 0;
|
||||
const logicalWidth = getLogicalGridSize(columnCount) - outerCardInset;
|
||||
const viewportHeight = Math.max(1, getLogicalGridSize(viewportRowCount) - outerCardInset - collapsibleHeaderInset);
|
||||
// Items are positioned in a coordinate space sized to the *un-inset* column/row count
|
||||
// (fullGridWidth/Height below - see getLogicalItemStyle), but logicalWidth/Height above are
|
||||
// deliberately smaller by outerCardInset to match the container's actual visible card size.
|
||||
// Left alone, that mismatch means the items - not just the grid's own box - spill past the
|
||||
// card's right/bottom edge by exactly that inset. Scale the grid's content down by the same
|
||||
// ratio the board's own canvas uses for its whole-board zoom (see ScaledBoardCanvas), just one
|
||||
// level deeper, so it fits the actual card instead of clipping. --board-canvas-ui-scale is
|
||||
// corrected to compensate so icon/text sizing inside the container isn't affected.
|
||||
const fullGridWidth = getLogicalGridSize(columnCount);
|
||||
const fullGridHeight = getLogicalGridSize(rowCount);
|
||||
const logicalWidth = fullGridWidth;
|
||||
const viewportHeight = Math.max(1, getLogicalGridSize(viewportRowCount) - collapsibleHeaderInset);
|
||||
// For a scrollable container, rowCount covers *all* content rows, not just the visible card -
|
||||
// fullGridHeight grows right along with it, so a ratio built from it drifts toward 1 as content
|
||||
// grows regardless of the (fixed) inset, under-scaling relative to what the actually-visible
|
||||
@@ -184,16 +159,9 @@ export const SectionGrid = ({
|
||||
// describe the real visible card size in both cases (they equal the non-scrollable values when
|
||||
// rowCount and viewportRowCount are the same), so use those instead.
|
||||
const fullViewportHeight = getLogicalGridSize(viewportRowCount);
|
||||
// A uniform zoom is required (not a non-uniform transform scale(x, y)) - --board-canvas-ui-scale
|
||||
// is a single scalar that every icon/text/custom-CSS size compensation in the app multiplies
|
||||
// by, so a non-uniform stretch can never be captured by it correctly. That means one axis can
|
||||
// be left with a small amount of unfilled slack when width/height need different ratios to
|
||||
// exactly fit - centered below so it's even on both sides rather than left-aligned.
|
||||
// logicalWidth/viewportHeight can go non-positive for a narrow (e.g. single-column) container
|
||||
// on a heavily zoomed-out board, where outerCardInset (which grows as canvasScale shrinks) can
|
||||
// exceed the container's own un-inset size. Floor the scale well above 0 so it can never reach
|
||||
// zero or negative - combinedUiScale divides by this value, and this value is also used as
|
||||
// zoom directly, both of which break (Infinity/NaN, or invalid/collapsed content) otherwise.
|
||||
// A collapsible container reserves a fixed-height label above the square-cell grid. Keep the
|
||||
// nested content uniformly scaled within the remaining height so labels and icons retain their
|
||||
// proportions; ordinary containers use the original unscaled, edge-aligned grid geometry.
|
||||
const containerContentScale =
|
||||
section.kind === "container" && fullGridWidth > 0 && fullViewportHeight > 0
|
||||
? Math.max(0.01, Math.min(logicalWidth / fullGridWidth, viewportHeight / fullViewportHeight, 1))
|
||||
@@ -302,6 +270,7 @@ export const SectionGrid = ({
|
||||
height: `var(--board-grid-drag-height, ${viewportHeight}px)`,
|
||||
marginTop: collapsibleHeaderInset || undefined,
|
||||
"--board-item-radius": `var(--mantine-radius-${board.itemRadius})`,
|
||||
"--board-grid-content-scale": containerContentScale,
|
||||
} as CSSProperties
|
||||
}
|
||||
data-section-id={section.id}
|
||||
|
||||
@@ -33,8 +33,8 @@ export const useGridLayoutActions = () => {
|
||||
placements.map((placement) => [`${layoutId}:${placement.id}`, { layoutId, sectionId, placement }] as const),
|
||||
),
|
||||
);
|
||||
let hasChanges = false;
|
||||
updateBoard((previous) => {
|
||||
let hasChanges = false;
|
||||
const items = previous.items.map((item) => {
|
||||
const layouts = item.layouts.map((layout) => {
|
||||
const resolved = byId.get(`${layout.layoutId}:${item.id}`);
|
||||
@@ -97,6 +97,7 @@ export const useGridLayoutActions = () => {
|
||||
|
||||
return hasChanges ? { ...previous, items, sections } : previous;
|
||||
});
|
||||
return hasChanges;
|
||||
},
|
||||
[updateBoard],
|
||||
);
|
||||
|
||||
@@ -21,6 +21,9 @@
|
||||
--border-color: rgb(
|
||||
from var(--container-border-color, var(--container-default-border-color)) r g b / var(--opacity, 1)
|
||||
);
|
||||
/* Keep the border visual without letting it shrink the square-cell grid's content box. */
|
||||
border-width: 0;
|
||||
box-shadow: inset 0 0 0 calc(1px * var(--board-canvas-inverse-scale, 1)) var(--border-color);
|
||||
}
|
||||
|
||||
.itemCard:global(.iframe-wrapper) {
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
interface UnsavedChangesGuardOptions {
|
||||
isDirty(): boolean;
|
||||
confirmNavigation(href: string): void;
|
||||
guardBeforeUnload?: boolean;
|
||||
}
|
||||
|
||||
export function registerUnsavedChangesGuard({ isDirty, confirmNavigation }: UnsavedChangesGuardOptions) {
|
||||
export function registerUnsavedChangesGuard({
|
||||
isDirty,
|
||||
confirmNavigation,
|
||||
guardBeforeUnload = true,
|
||||
}: UnsavedChangesGuardOptions) {
|
||||
let currentHref = `${window.location.pathname}${window.location.search}${window.location.hash}`;
|
||||
const handleClick = (event: MouseEvent) => {
|
||||
if (!isDirty() || event.defaultPrevented || event.button !== 0) return;
|
||||
@@ -17,6 +22,7 @@ export function registerUnsavedChangesGuard({ isDirty, confirmNavigation }: Unsa
|
||||
const current = new URL(window.location.href);
|
||||
if (destination.origin !== current.origin) return;
|
||||
if (destination.pathname === current.pathname && destination.search === current.search) {
|
||||
currentHref = `${destination.pathname}${destination.search}${destination.hash}`;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -48,11 +54,11 @@ export function registerUnsavedChangesGuard({ isDirty, confirmNavigation }: Unsa
|
||||
};
|
||||
|
||||
document.addEventListener("click", handleClick, true);
|
||||
window.addEventListener("beforeunload", handleBeforeUnload);
|
||||
if (guardBeforeUnload) window.addEventListener("beforeunload", handleBeforeUnload);
|
||||
window.addEventListener("popstate", handlePopState);
|
||||
return () => {
|
||||
document.removeEventListener("click", handleClick, true);
|
||||
window.removeEventListener("beforeunload", handleBeforeUnload);
|
||||
if (guardBeforeUnload) window.removeEventListener("beforeunload", handleBeforeUnload);
|
||||
window.removeEventListener("popstate", handlePopState);
|
||||
};
|
||||
}
|
||||
@@ -7,7 +7,11 @@ import { useConfirmModal } from "@homarr/modals";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { registerUnsavedChangesGuard } from "./unsaved-changes-guard";
|
||||
|
||||
export function useUnsavedChangesGuard(isDirty: boolean) {
|
||||
interface UnsavedChangesGuardOptions {
|
||||
guardBeforeUnload?: boolean;
|
||||
}
|
||||
|
||||
export function useUnsavedChangesGuard(isDirty: boolean, options: UnsavedChangesGuardOptions = {}) {
|
||||
const tConfirmLeave = useI18n("board.action.edit.confirmLeave");
|
||||
const tCommon = useI18n("common");
|
||||
const router = useRouter();
|
||||
@@ -39,7 +43,8 @@ export function useUnsavedChangesGuard(isDirty: boolean) {
|
||||
registerUnsavedChangesGuard({
|
||||
isDirty: () => dirtyRef.current,
|
||||
confirmNavigation: (href) => confirmNavigationRef.current(href),
|
||||
guardBeforeUnload: options.guardBeforeUnload,
|
||||
}),
|
||||
[],
|
||||
[options.guardBeforeUnload],
|
||||
);
|
||||
}
|
||||
@@ -89,7 +89,7 @@ describe("Onboarding", () => {
|
||||
|
||||
try {
|
||||
await page.goto(`http://${homarrContainer.getHost()}:${homarrContainer.getMappedPort(7575)}/init`);
|
||||
await page.getByRole("heading", { name: "Make home feel organized." }).waitFor();
|
||||
await page.getByRole("heading", { name: "Welcome home." }).waitFor();
|
||||
const start = page.getByRole("button", { name: "Get started" });
|
||||
await start.focus();
|
||||
await start.press("Enter");
|
||||
@@ -356,7 +356,7 @@ describe("Onboarding", () => {
|
||||
);
|
||||
|
||||
await loginAsync({ page, baseUrl, credentials, destination: "/init" });
|
||||
await page.getByRole("heading", { name: "Make home feel organized." }).waitFor();
|
||||
await page.getByRole("heading", { name: "Welcome home." }).waitFor();
|
||||
await page.getByRole("button", { name: "Get started" }).click();
|
||||
await page.getByRole("heading", { name: "Start with familiar defaults" }).waitFor();
|
||||
expect(await page.getByRole("heading", { name: "Create your administrator" }).count()).toBe(0);
|
||||
|
||||
@@ -51,11 +51,13 @@
|
||||
"@homarr/validation": "workspace:^0.1.0",
|
||||
"@homarr/workshop": "workspace:^0.1.0",
|
||||
"@kubernetes/client-node": "catalog:",
|
||||
"@modelcontextprotocol/server": "catalog:",
|
||||
"@tanstack/react-query": "catalog:",
|
||||
"@trpc/client": "catalog:",
|
||||
"@trpc/react-query": "catalog:",
|
||||
"@trpc/server": "catalog:",
|
||||
"@trpc/tanstack-react-query": "catalog:",
|
||||
"ajv": "catalog:",
|
||||
"lodash.clonedeep": "catalog:",
|
||||
"next": "catalog:",
|
||||
"react": "catalog:",
|
||||
|
||||
+116
-62
@@ -1,3 +1,5 @@
|
||||
import { Ajv2020 } from "ajv/dist/2020.js";
|
||||
import { addFormats, AjvJsonSchemaValidator } from "@modelcontextprotocol/server/validators/ajv";
|
||||
import type { AnyProcedure, AnyRootTypes, Router, RouterRecord } from "@trpc/server/unstable-core-do-not-import";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
@@ -15,23 +17,35 @@ export interface McpTool {
|
||||
type: "query" | "mutation";
|
||||
pathInRouter: string[];
|
||||
inputSchema: z.core.JSONSchema.JSONSchema;
|
||||
inputValidator: z.ZodObject;
|
||||
inputMode: "none" | "object";
|
||||
}
|
||||
|
||||
const emptyInput = z.object({});
|
||||
export interface McpToolDiagnostic {
|
||||
name: string;
|
||||
path: string;
|
||||
reason: "unsupported_input" | "invalid_schema" | "duplicate_name" | "invalid_name";
|
||||
}
|
||||
|
||||
export interface McpToolCatalog {
|
||||
tools: McpTool[];
|
||||
diagnostics: McpToolDiagnostic[];
|
||||
}
|
||||
|
||||
const emptyInput = z.strictObject({});
|
||||
const schemaEngine = new Ajv2020({
|
||||
strict: false,
|
||||
validateSchema: true,
|
||||
coerceTypes: false,
|
||||
useDefaults: false,
|
||||
removeAdditional: false,
|
||||
});
|
||||
addFormats(schemaEngine);
|
||||
const schemaValidator = new AjvJsonSchemaValidator(schemaEngine);
|
||||
const isProcedure = (value: AnyProcedure | RouterRecord): value is AnyProcedure => typeof value === "function";
|
||||
|
||||
const mergeInputs = (inputs: z.ZodObject[]) => inputs.reduce((merged, input) => merged.extend(input.shape), emptyInput);
|
||||
|
||||
const getObjectInput = (input: z.ZodType) => {
|
||||
const getObjectInput = (input: unknown): z.ZodObject | null => {
|
||||
if (input instanceof z.ZodObject) return input;
|
||||
|
||||
if (input instanceof z.ZodOptional) {
|
||||
const unwrappedInput = input.unwrap();
|
||||
if (unwrappedInput instanceof z.ZodObject) return unwrappedInput;
|
||||
}
|
||||
|
||||
if (input instanceof z.ZodOptional) return getObjectInput(input.unwrap());
|
||||
return null;
|
||||
};
|
||||
|
||||
@@ -50,70 +64,110 @@ const getMcpMetadata = (meta: unknown): McpMeta["mcp"] => {
|
||||
return result;
|
||||
};
|
||||
|
||||
const getProcedureInput = (inputs: z.ZodType[]) => {
|
||||
if (inputs.length === 0) return emptyInput;
|
||||
if (inputs.length === 1) {
|
||||
const input = inputs[0] ?? emptyInput;
|
||||
return getObjectInput(input) ?? input;
|
||||
}
|
||||
|
||||
const objectInputs = inputs.map(getObjectInput);
|
||||
if (objectInputs.every((input): input is z.ZodObject => input !== null)) return mergeInputs(objectInputs);
|
||||
return null;
|
||||
};
|
||||
|
||||
const normalizeJsonSchema = (schema: z.core.JSONSchema.JSONSchema): z.core.JSONSchema.JSONSchema => {
|
||||
const properties = schema.properties ?? {};
|
||||
const required = (schema.required ?? []).filter((key) => {
|
||||
const property = properties[key];
|
||||
return !property || typeof property !== "object" || !("default" in property);
|
||||
});
|
||||
|
||||
return {
|
||||
...schema,
|
||||
type: "object",
|
||||
properties,
|
||||
required,
|
||||
};
|
||||
};
|
||||
|
||||
export function extractMcpToolsFromProcedures<TRoot extends AnyRootTypes, TRecord extends RouterRecord>(
|
||||
router: Router<TRoot, TRecord>,
|
||||
): McpTool[] {
|
||||
): McpToolCatalog {
|
||||
const tools: McpTool[] = [];
|
||||
const diagnostics: McpToolDiagnostic[] = [];
|
||||
const names = new Map<string, string[]>();
|
||||
|
||||
for (const [path, procedure] of Object.entries(router["_def"].procedures)) {
|
||||
if (!isProcedure(procedure)) continue;
|
||||
const definition = procedure["_def"];
|
||||
if (definition.type === "subscription") continue;
|
||||
|
||||
const metadata = getMcpMetadata(definition.meta);
|
||||
if (!metadata?.enabled) continue;
|
||||
|
||||
const inputs = definition.inputs.filter((input): input is z.ZodType => input instanceof z.ZodType);
|
||||
const procedureInput = getProcedureInput(inputs);
|
||||
|
||||
if (
|
||||
procedureInput === null ||
|
||||
(!(procedureInput instanceof z.ZodObject) && !procedureInput.safeParse(undefined).success)
|
||||
) {
|
||||
console.warn(`[MCP] Procedure ${path} does not use an object input schema; using an empty schema`);
|
||||
const name = metadata.name ?? path.replaceAll(".", "_");
|
||||
const paths = names.get(name) ?? [];
|
||||
paths.push(path);
|
||||
names.set(name, paths);
|
||||
if (!/^[a-zA-Z0-9_.-]{1,128}$/.test(name)) {
|
||||
diagnostics.push({ name, path, reason: "invalid_name" });
|
||||
continue;
|
||||
}
|
||||
const inputValidator = procedureInput instanceof z.ZodObject ? procedureInput : emptyInput;
|
||||
|
||||
tools.push({
|
||||
name: metadata.name ?? path.replaceAll(".", "_"),
|
||||
description: metadata.description ?? "",
|
||||
type: definition.type,
|
||||
pathInRouter: path.split("."),
|
||||
inputValidator,
|
||||
inputSchema: normalizeJsonSchema(
|
||||
z.toJSONSchema(inputValidator, {
|
||||
unrepresentable: "any",
|
||||
}),
|
||||
),
|
||||
});
|
||||
const inputs = definition.inputs;
|
||||
let inputMode: McpTool["inputMode"] = "object";
|
||||
if (inputs.every((input) => input instanceof z.ZodUndefined || input instanceof z.ZodVoid)) inputMode = "none";
|
||||
const objectInputs = inputs.map(getObjectInput);
|
||||
if (inputMode === "object" && !objectInputs.every((input) => input !== null)) {
|
||||
diagnostics.push({ name, path, reason: "unsupported_input" });
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
let input: z.ZodType = emptyInput;
|
||||
if (inputMode === "object") {
|
||||
const objects = objectInputs.filter((schema): schema is z.ZodObject => schema !== null);
|
||||
input = objects[0] ?? emptyInput;
|
||||
for (const object of objects.slice(1)) input = z.intersection(input, object);
|
||||
}
|
||||
const inputSchema = z.toJSONSchema(input, {
|
||||
io: "input",
|
||||
override: ({ zodSchema, jsonSchema }) => {
|
||||
if (zodSchema["_zod"].def.type !== "string") return;
|
||||
if (zodSchema["_zod"].def.checks?.some((check) => check["_zod"].def.check === "overwrite")) {
|
||||
// trim/case normalization runs before these checks in tRPC. They cannot constrain raw JSON.
|
||||
delete jsonSchema.minLength;
|
||||
delete jsonSchema.maxLength;
|
||||
delete jsonSchema.pattern;
|
||||
delete jsonSchema.allOf;
|
||||
delete jsonSchema.format;
|
||||
}
|
||||
// Zod's ISO regex permits minute precision; JSON Schema date-time requires seconds.
|
||||
if (jsonSchema.format === "date-time" && typeof jsonSchema.pattern === "string") {
|
||||
delete jsonSchema.format;
|
||||
}
|
||||
},
|
||||
});
|
||||
// Chained tRPC object inputs produce allOf; MCP requires an explicit object root.
|
||||
if (inputSchema.type !== undefined && inputSchema.type !== "object") throw new Error("Invalid root");
|
||||
inputSchema.type = "object";
|
||||
// Zod and the SDK disagree on JSON Schema vocabulary types; compilation checks the actual schema.
|
||||
schemaValidator.getValidator(inputSchema as Parameters<typeof schemaValidator.getValidator>[0]);
|
||||
tools.push({
|
||||
name,
|
||||
description: metadata.description ?? "",
|
||||
type: definition.type,
|
||||
pathInRouter: path.split("."),
|
||||
inputMode,
|
||||
inputSchema,
|
||||
});
|
||||
} catch {
|
||||
// Conversion errors can contain schema metadata. Only expose a stable, safe reason.
|
||||
diagnostics.push({ name, path, reason: "invalid_schema" });
|
||||
}
|
||||
}
|
||||
|
||||
return tools.toSorted((left, right) => left.name.localeCompare(right.name));
|
||||
const duplicates = new Set<string>();
|
||||
for (const [name, paths] of names) {
|
||||
if (paths.length < 2) continue;
|
||||
duplicates.add(name);
|
||||
for (const path of paths) diagnostics.push({ name, path, reason: "duplicate_name" });
|
||||
}
|
||||
return {
|
||||
tools: tools
|
||||
.filter((tool) => !duplicates.has(tool.name))
|
||||
.toSorted((left, right) => left.name.localeCompare(right.name)),
|
||||
diagnostics: diagnostics.toSorted(
|
||||
(left, right) => left.path.localeCompare(right.path) || left.reason.localeCompare(right.reason),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/** Dispatch raw JSON through the caller so tRPC owns parsing, transforms, and authorization. */
|
||||
export async function callMcpTool(caller: unknown, tool: McpTool, input: unknown) {
|
||||
let procedure = caller;
|
||||
for (const segment of tool.pathInRouter) {
|
||||
if ((typeof procedure !== "object" && typeof procedure !== "function") || procedure === null) {
|
||||
throw new Error("Tool is not callable");
|
||||
}
|
||||
procedure = (procedure as Record<string, unknown>)[segment];
|
||||
}
|
||||
if (typeof procedure !== "function") throw new Error("Tool is not callable");
|
||||
let procedureInput = input;
|
||||
if (tool.inputMode === "none") procedureInput = undefined;
|
||||
else if (input === undefined) procedureInput = {};
|
||||
return await (procedure as (value: unknown) => Promise<unknown>)(procedureInput);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
export { createTRPCContext } from "./trpc";
|
||||
export { extractMcpToolsFromProcedures } from "./mcp-tools";
|
||||
export { callMcpTool, extractMcpToolsFromProcedures } from "./mcp-tools";
|
||||
export type { McpTool } from "./mcp-tools";
|
||||
|
||||
import { createTRPCRouter } from "./trpc";
|
||||
|
||||
@@ -56,7 +56,7 @@ export const inviteRouter = createTRPCRouter({
|
||||
.requiresPermission("admin")
|
||||
.input(
|
||||
z.object({
|
||||
expirationDate: z.date(),
|
||||
expirationDate: z.iso.datetime({ offset: true }),
|
||||
}),
|
||||
)
|
||||
.output(z.object({ id: z.string(), token: z.string() }))
|
||||
@@ -80,7 +80,7 @@ export const inviteRouter = createTRPCRouter({
|
||||
|
||||
await ctx.db.insert(invites).values({
|
||||
id,
|
||||
expirationDate: input.expirationDate,
|
||||
expirationDate: new Date(input.expirationDate),
|
||||
creatorId: ctx.session.user.id,
|
||||
token,
|
||||
});
|
||||
|
||||
@@ -123,7 +123,7 @@ describe("create should create a new invite expiring on the specified date with
|
||||
|
||||
// Act
|
||||
const result = await caller.createInvite({
|
||||
expirationDate,
|
||||
expirationDate: expirationDate.toISOString(),
|
||||
});
|
||||
|
||||
// Assert
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { initTRPC } from "@trpc/server";
|
||||
import type { TRPCError } from "@trpc/server";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { mcpRouter } from "../mcp";
|
||||
import { extractMcpToolsFromProcedures } from "../mcp-tools";
|
||||
import type { McpMeta } from "../mcp-tools";
|
||||
import { createMcpProtocolHandler } from "../../../../apps/nextjs/src/app/api/mcp/_protocol";
|
||||
import { callMcpTool, extractMcpToolsFromProcedures } from "../mcp-tools";
|
||||
|
||||
vi.mock("@homarr/auth", () => ({}));
|
||||
|
||||
@@ -157,7 +160,7 @@ function actualToolInventory() {
|
||||
{ _def?: { type?: "query" | "mutation" } }
|
||||
>;
|
||||
return extractMcpToolsFromProcedures(mcpRouter)
|
||||
.map((tool) => {
|
||||
.tools.map((tool) => {
|
||||
const procedure = procedures[tool.pathInRouter.join(".")];
|
||||
const type = procedure?.["_def"]?.type;
|
||||
if (type !== "query" && type !== "mutation") throw new Error(`Unable to classify MCP tool '${tool.name}'`);
|
||||
@@ -175,7 +178,7 @@ describe("production MCP router", () => {
|
||||
});
|
||||
|
||||
test("keeps every secret-bearing tool behind a mutation procedure", () => {
|
||||
const tools = extractMcpToolsFromProcedures(mcpRouter);
|
||||
const tools = extractMcpToolsFromProcedures(mcpRouter).tools;
|
||||
const procedures = mcpRouter["_def"].procedures as unknown as Record<
|
||||
string,
|
||||
{ _def?: { type?: "query" | "mutation" } }
|
||||
@@ -189,7 +192,7 @@ describe("production MCP router", () => {
|
||||
});
|
||||
|
||||
test("gives every production tool a description", () => {
|
||||
const tools = extractMcpToolsFromProcedures(mcpRouter);
|
||||
const tools = extractMcpToolsFromProcedures(mcpRouter).tools;
|
||||
for (const tool of tools) expect(tool.description, `Tool ${tool.name} should have a description`).toBeTruthy();
|
||||
expect(tools.find((tool) => tool.name === "customWidget_getAuthoringPrompt")?.description).toBe(
|
||||
"Get the current Custom Widget authoring instructions.",
|
||||
@@ -197,16 +200,17 @@ describe("production MCP router", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("MCP tools are deterministically ordered and retain executable schemas", () => {
|
||||
const tools = extractMcpToolsFromProcedures(mcpRouter);
|
||||
test("MCP tools are deterministically ordered and have valid production schemas", () => {
|
||||
expect(extractMcpToolsFromProcedures(mcpRouter).diagnostics).toEqual([]);
|
||||
const tools = extractMcpToolsFromProcedures(mcpRouter).tools;
|
||||
const toolNames = tools.map((tool) => tool.name);
|
||||
|
||||
expect(toolNames).toEqual(toolNames.toSorted((left, right) => left.localeCompare(right)));
|
||||
expect(tools.every((tool) => tool.inputValidator instanceof z.ZodObject)).toBe(true);
|
||||
expect(tools.every((tool) => tool.inputSchema.type === "object")).toBe(true);
|
||||
});
|
||||
|
||||
test("publishes both Custom Widget template input formats", () => {
|
||||
const tool = extractMcpToolsFromProcedures(mcpRouter).find(
|
||||
const tool = extractMcpToolsFromProcedures(mcpRouter).tools.find(
|
||||
(candidate) => candidate.name === "customWidget_validateTemplate",
|
||||
);
|
||||
|
||||
@@ -355,3 +359,119 @@ describe("custom widget authoring procedure access", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("MCP catalog diagnostics", () => {
|
||||
const trpc = initTRPC.meta<McpMeta>().create();
|
||||
const exposed = trpc.procedure.meta({ mcp: { enabled: true } });
|
||||
|
||||
test("omits unsupported parsers and malformed schemas without losing valid tools", () => {
|
||||
const router = trpc.router({
|
||||
good: exposed.input(z.object({ name: z.string() })).query(() => "ok"),
|
||||
date: exposed.input(z.object({ date: z.date() })).query(() => "unused"),
|
||||
scalar: exposed.input(z.string()).query(() => "unused"),
|
||||
parser: exposed.input({ parse: (value: unknown) => value }).query(() => "unused"),
|
||||
malformed: exposed
|
||||
.input(z.object({ value: z.string().meta({ type: "invalid-json-type" }) }))
|
||||
.query(() => "unused"),
|
||||
});
|
||||
const catalog = extractMcpToolsFromProcedures(router);
|
||||
expect(catalog.tools.map((tool) => tool.name)).toEqual(["good"]);
|
||||
expect(catalog.diagnostics).toEqual([
|
||||
{ name: "date", path: "date", reason: "invalid_schema" },
|
||||
{ name: "malformed", path: "malformed", reason: "invalid_schema" },
|
||||
{ name: "parser", path: "parser", reason: "unsupported_input" },
|
||||
{ name: "scalar", path: "scalar", reason: "unsupported_input" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("omits all duplicate names even when one procedure has an invalid schema", () => {
|
||||
const shared = trpc.procedure.meta({ mcp: { enabled: true, name: "shared" } });
|
||||
const catalog = extractMcpToolsFromProcedures(
|
||||
trpc.router({
|
||||
first: shared.query(() => "first"),
|
||||
second: shared.input(z.object({ date: z.date() })).query(() => "second"),
|
||||
good: exposed.query(() => "good"),
|
||||
invalidName: trpc.procedure.meta({ mcp: { enabled: true, name: "bad name" } }).query(() => "unused"),
|
||||
}),
|
||||
);
|
||||
expect(catalog.tools.map((tool) => tool.name)).toEqual(["good"]);
|
||||
expect(catalog.diagnostics.filter((diagnostic) => diagnostic.reason === "duplicate_name")).toEqual([
|
||||
{ name: "shared", path: "first", reason: "duplicate_name" },
|
||||
{ name: "shared", path: "second", reason: "duplicate_name" },
|
||||
]);
|
||||
expect(catalog.diagnostics).toContainEqual({ name: "bad name", path: "invalidName", reason: "invalid_name" });
|
||||
});
|
||||
|
||||
test("distinguishes empty object input from no input for shared Assistant dispatch", async () => {
|
||||
const router = trpc.router({
|
||||
none: exposed.query(({ input }) => input),
|
||||
explicitNone: exposed.input(z.undefined()).query(({ input }) => input),
|
||||
object: exposed.input(z.object({ value: z.string().default("default") })).query(({ input }) => input),
|
||||
optional: exposed
|
||||
.input(z.object({ value: z.string().default("default") }).optional())
|
||||
.query(({ input }) => input),
|
||||
});
|
||||
const catalog = extractMcpToolsFromProcedures(router);
|
||||
expect(catalog.diagnostics).toEqual([]);
|
||||
for (const tool of catalog.tools) {
|
||||
const result = await callMcpTool(router.createCaller({}), tool, {});
|
||||
if (tool.inputMode === "none") expect(result).toBeUndefined();
|
||||
else expect(result).toEqual({ value: "default" });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.each([false, true])(
|
||||
"production catalog registers through the real protocol handler (modern=%s)",
|
||||
async (modern) => {
|
||||
const { tools, diagnostics } = extractMcpToolsFromProcedures(mcpRouter);
|
||||
expect(diagnostics).toEqual([]);
|
||||
const handler = createMcpProtocolHandler({
|
||||
caller: {},
|
||||
tools,
|
||||
version: "test",
|
||||
instructions: "test",
|
||||
formatToolError: () => "Tool failed",
|
||||
});
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json, text/event-stream",
|
||||
};
|
||||
const params: Record<string, unknown> = {};
|
||||
if (modern) {
|
||||
headers["Mcp-Method"] = "tools/list";
|
||||
headers["Mcp-Name"] = "homarr";
|
||||
params["_meta"] = {
|
||||
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
|
||||
"io.modelcontextprotocol/clientInfo": { name: "catalog-test", version: "1" },
|
||||
"io.modelcontextprotocol/clientCapabilities": {},
|
||||
};
|
||||
}
|
||||
const response = await handler.fetch(
|
||||
new Request("http://homarr.test/api/mcp", {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list", params }),
|
||||
}),
|
||||
);
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.has("Mcp-Session-Id")).toBe(false);
|
||||
const body = await response.text();
|
||||
let payload = body;
|
||||
if (response.headers.get("Content-Type")?.includes("text/event-stream")) {
|
||||
payload =
|
||||
body
|
||||
.split("\n")
|
||||
.find((line) => line.startsWith("data: "))
|
||||
?.slice(6) ?? "";
|
||||
}
|
||||
const result = JSON.parse(payload) as {
|
||||
error?: unknown;
|
||||
result: { tools: { name: string; inputSchema: unknown }[] };
|
||||
};
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.result.tools.map((tool) => ({ name: tool.name, inputSchema: tool.inputSchema }))).toEqual(
|
||||
tools.map((tool) => ({ name: tool.name, inputSchema: tool.inputSchema })),
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -12,13 +12,13 @@ import {
|
||||
Text,
|
||||
ThemeIcon,
|
||||
} from "@mantine/core";
|
||||
import { IconListCheck, IconPlus, IconSearch } from "@tabler/icons-react";
|
||||
import { IconBulb, IconListCheck, IconPlus, IconSearch } from "@tabler/icons-react";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { createModal, modalSizeSelect, useModalAction } from "@homarr/modals";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { selectGridCols, SelectableCard } from "@homarr/ui";
|
||||
import { FloatingTip, selectGridCols, SelectableCard } from "@homarr/ui";
|
||||
|
||||
import { QuickAddAppModal } from "./quick-add-app/quick-add-app-modal";
|
||||
|
||||
@@ -106,6 +106,17 @@ export const AppSelectModal = createModal<AppSelectModalProps>(({ actions, inner
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<FloatingTip
|
||||
opened={multiSelectAvailable}
|
||||
showDelay={2_000}
|
||||
dismissAfter={3_000}
|
||||
transitionDuration={200}
|
||||
closable={false}
|
||||
alertProps={{ color: "primaryColor", icon: <IconBulb size={18} />, variant: "light" }}
|
||||
>
|
||||
{t("tips.multiSelectApps")}
|
||||
</FloatingTip>
|
||||
|
||||
{/* Top Search Input */}
|
||||
<Stack gap={6}>
|
||||
<Input
|
||||
|
||||
@@ -27,11 +27,11 @@
|
||||
"review": "Review"
|
||||
},
|
||||
"welcome": {
|
||||
"title": "Make home feel organized.",
|
||||
"title": "Welcome home.",
|
||||
"description": "Welcome to Homarr v{version}. We’ll guide you through the essentials and help you build your first board.",
|
||||
"start": "Get started",
|
||||
"restore": "Restore backup",
|
||||
"restoreTitle": "Restore this Homarr",
|
||||
"restoreTitle": "Restore from backup",
|
||||
"restoreDescription": "A full SQLite backup restores boards, people, apps, integrations, and settings. Homarr validates it before replacing anything.",
|
||||
"errorTitle": "Setup could not start",
|
||||
"errorDescription": "Your setup choices are still here. Try again, or reload the page if this continues."
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import type { CSSProperties } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
Avatar,
|
||||
@@ -144,9 +145,21 @@ export default function BookmarksWidget({
|
||||
if (appIds.length > 0 && isInitialWidgetQueryPending(appsQuery)) return <WidgetQueryLoadingState />;
|
||||
|
||||
const isTight = responsiveHeight < 120;
|
||||
let compactStyle: CSSProperties | undefined;
|
||||
if (!advanced && Number.isFinite(displayScale) && displayScale > 1) {
|
||||
// Low-column boards zoom the canvas above 100%. Keep bookmark controls at their intended physical size.
|
||||
// The spacing and font tokens are rebound here because their inherited values resolve at the canvas level.
|
||||
compactStyle = {
|
||||
"--board-canvas-ui-scale": 1 / displayScale,
|
||||
"--mantine-scale": 1 / displayScale,
|
||||
"--mantine-font-size-xxs": "calc(0.6875rem * var(--board-canvas-ui-scale))",
|
||||
"--mantine-spacing-sm": "calc(0.75rem * var(--board-canvas-ui-scale))",
|
||||
"--mantine-spacing-xs": "calc(0.625rem * var(--board-canvas-ui-scale))",
|
||||
} as CSSProperties;
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack h="100%" mih={0} gap={isTight ? 6 : "sm"} p={isTight ? 6 : "sm"}>
|
||||
<Stack h="100%" mih={0} gap={isTight ? 6 : "sm"} p={isTight ? 6 : "sm"} style={compactStyle}>
|
||||
{options.title.length > 0 ? (
|
||||
<Text fz={11} fw={600} px={2} lh={1.2} lineClamp={1}>
|
||||
{options.title}
|
||||
|
||||
@@ -40,7 +40,8 @@ export const getBookmarkCardDisplay = ({
|
||||
orientation = "horizontal";
|
||||
showTitle = true;
|
||||
}
|
||||
const showHostname = advanced || (!hideHostname && plan.showHostname);
|
||||
let showHostname = advanced || (!hideHostname && plan.showHostname);
|
||||
if (!advanced && plan.itemHeight <= 32 && showTitle) showHostname = false;
|
||||
const showIcon = advanced || !hideIcon || (!showTitle && !showHostname);
|
||||
|
||||
return { orientation, showHostname, showIcon, showTitle };
|
||||
@@ -246,23 +247,15 @@ export const getBookmarkDisplayPlan = ({
|
||||
}
|
||||
|
||||
if (layout === "column") {
|
||||
const densitySettings = getBookmarkDensitySettings({
|
||||
columns: 1,
|
||||
gap,
|
||||
height: normalizedHeight,
|
||||
heightProperty: "columnItemHeight",
|
||||
itemCount: count,
|
||||
preferredSettings: heightSettings,
|
||||
});
|
||||
return {
|
||||
columns: 1,
|
||||
horizontalScroll: false,
|
||||
itemGap: getBookmarkDensityGap(gap, densitySettings),
|
||||
itemHeight: densitySettings.columnItemHeight,
|
||||
itemGap: Math.min(gap, 4),
|
||||
itemHeight: 32,
|
||||
itemWidth: width,
|
||||
orientation: "horizontal",
|
||||
showHostname: widthSettings.showHostname && densitySettings.showHostname,
|
||||
showTitle: widthSettings.showTitle && densitySettings.showTitle,
|
||||
showHostname: widthSettings.showHostname,
|
||||
showTitle: widthSettings.showTitle,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Generated
+9
@@ -366,6 +366,9 @@ catalogs:
|
||||
ai:
|
||||
specifier: ^7.0.56
|
||||
version: 7.0.56
|
||||
ajv:
|
||||
specifier: 8.18.0
|
||||
version: 8.18.0
|
||||
assistant-stream:
|
||||
specifier: 0.3.34
|
||||
version: 0.3.34
|
||||
@@ -1520,6 +1523,9 @@ importers:
|
||||
'@kubernetes/client-node':
|
||||
specifier: 'catalog:'
|
||||
version: 1.4.0(supports-color@10.2.2)
|
||||
'@modelcontextprotocol/server':
|
||||
specifier: 'catalog:'
|
||||
version: 2.0.0
|
||||
'@tanstack/react-query':
|
||||
specifier: 'catalog:'
|
||||
version: 5.101.2(react@19.2.7)
|
||||
@@ -1535,6 +1541,9 @@ importers:
|
||||
'@trpc/tanstack-react-query':
|
||||
specifier: 'catalog:'
|
||||
version: 11.18.0(@tanstack/react-query@5.101.2(react@19.2.7))(@trpc/client@11.18.0(@trpc/server@11.18.0(typescript@7.0.2))(typescript@7.0.2))(@trpc/server@11.18.0(typescript@7.0.2))(react@19.2.7)(typescript@7.0.2)
|
||||
ajv:
|
||||
specifier: 'catalog:'
|
||||
version: 8.18.0
|
||||
lodash.clonedeep:
|
||||
specifier: 'catalog:'
|
||||
version: 4.5.0
|
||||
|
||||
@@ -123,6 +123,7 @@ catalog:
|
||||
adm-zip: 0.6.0
|
||||
acorn: ^8.16.0
|
||||
acorn-jsx: ^5.3.2
|
||||
ajv: 8.18.0
|
||||
jszip: ^3.10.1
|
||||
sql.js: ^1.14.1
|
||||
babel-plugin-react-compiler: ^1.0.0
|
||||
|
||||
Reference in new issue
Block a user