From 3358b6337fa7cb3789a858d92ed9024eb0cd30f4 Mon Sep 17 00:00:00 2001 From: Thomas Date: Thu, 10 Sep 2026 17:53:38 +0200 Subject: [PATCH 1/4] fix(mcp): use JSON-compatible invitation dates (#6814) --- apps/docs/docs/management/api/index.mdx | 6 ++++++ apps/docs/docs/management/mcp.mdx | 11 +++++++++++ apps/docs/static/api/open-api-schema.json | 4 +++- .../users/invites/_components/invite-create-form.tsx | 2 +- packages/api/src/mcp-tools.ts | 1 + packages/api/src/router/invite.ts | 4 ++-- packages/api/src/router/test/invite.spec.ts | 2 +- 7 files changed, 25 insertions(+), 5 deletions(-) diff --git a/apps/docs/docs/management/api/index.mdx b/apps/docs/docs/management/api/index.mdx index c975f9a9f..c89882c3e 100644 --- a/apps/docs/docs/management/api/index.mdx +++ b/apps/docs/docs/management/api/index.mdx @@ -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: diff --git a/apps/docs/docs/management/mcp.mdx b/apps/docs/docs/management/mcp.mdx index 39f7f2229..3a37ce9da 100644 --- a/apps/docs/docs/management/mcp.mdx +++ b/apps/docs/docs/management/mcp.mdx @@ -55,6 +55,9 @@ the catalog by namespace, tool name, or description; matching categories expand 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. +Copy the complete generated API key, including both parts and the dot: `.`. Sending only the ID or token +returns `invalid_token`. Keep the full key private when sharing client configuration or diagnostics. + 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. @@ -79,12 +82,20 @@ curl -X POST \ The removed HTTP+SSE `/sse` and `/message` transports return `410 Gone`. +Successful `server/discover` or `initialize` responses confirm connectivity, but do not validate the tool catalog. +Use `tools/list` above to check that the client can discover tools. If it returns +`Date cannot be represented in JSON Schema`, the server failed to generate a tool schema; changing the API key will +not resolve that error. Update Homarr to a release containing the invitation date schema fix. + ## Tools and resources 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. +The `invite_createInvite` tool accepts `expirationDate` as an ISO 8601 timestamp with a timezone, such as +`2026-12-01T18:00:00Z` or `2026-12-01T19:00:00+01:00`. + 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. diff --git a/apps/docs/static/api/open-api-schema.json b/apps/docs/static/api/open-api-schema.json index 8a3df7032..847c90fe6 100644 --- a/apps/docs/static/api/open-api-schema.json +++ b/apps/docs/static/api/open-api-schema.json @@ -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": [ diff --git a/apps/nextjs/src/app/[locale]/manage/users/invites/_components/invite-create-form.tsx b/apps/nextjs/src/app/[locale]/manage/users/invites/_components/invite-create-form.tsx index c56aecf0b..8b2b88dc5 100644 --- a/apps/nextjs/src/app/[locale]/manage/users/invites/_components/invite-create-form.tsx +++ b/apps/nextjs/src/app/[locale]/manage/users/invites/_components/invite-create-form.tsx @@ -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) => { diff --git a/packages/api/src/mcp-tools.ts b/packages/api/src/mcp-tools.ts index d9ade36a0..ecfd06a4b 100644 --- a/packages/api/src/mcp-tools.ts +++ b/packages/api/src/mcp-tools.ts @@ -109,6 +109,7 @@ export function extractMcpToolsFromProcedures Date: Thu, 10 Sep 2026 19:14:31 +0200 Subject: [PATCH 2/4] ci: remove automatic test gates and trim test suites (#6816) * ci: remove automatic test gates and trim test suites * docs: remove CI audit report --- .github/workflows/ci.yml | 67 ++------- .github/workflows/workshop.yml | 41 +++-- .../advanced/development/getting-started.mdx | 22 +++ .../_copy-ai-prompt-button.spec.tsx | 31 ---- .../_custom-widget-legacy-migration.spec.ts | 36 ----- .../_custom-widget-preview-panel.spec.tsx | 11 -- .../navigation/dynamic-breadcrumb.spec.ts | 18 --- .../workshop/workshop-install-flow.spec.ts | 141 ------------------ apps/workshop/README.md | 2 +- apps/workshop/tests/workshop-contracts.mjs | 116 +------------- e2e/assistant-docs-screenshots.spec.ts | 3 +- e2e/custom-widgets-custom-jsx-v2.spec.ts | 114 -------------- e2e/home.spec.ts | 20 --- e2e/onboarding.spec.ts | 101 +------------ e2e/shared/mock-api-server.ts | 50 ------- package.json | 9 +- .../src/test/authoring-resources.spec.ts | 37 ----- .../src/test/workshop-translations.spec.ts | 74 --------- vitest.config.mts | 34 +++++ 19 files changed, 107 insertions(+), 820 deletions(-) delete mode 100644 apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-legacy-migration.spec.ts delete mode 100644 apps/nextjs/src/components/navigation/dynamic-breadcrumb.spec.ts delete mode 100644 e2e/custom-widgets-custom-jsx-v2.spec.ts delete mode 100644 e2e/home.spec.ts delete mode 100644 e2e/shared/mock-api-server.ts delete mode 100644 packages/custom-widgets/src/test/workshop-translations.spec.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1e99d0556..c300c9762 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -97,17 +97,16 @@ jobs: - name: Validate Custom Widget bundle budget run: | - pnpm --filter nextjs build + pnpm turbo run build --filter=@homarr/nextjs pnpm check:custom-widget-bundle - - name: Run unit tests - run: pnpm test - container: - name: Container and E2E + name: Container build + # Same-repository previews already build amd64; only build a fallback when no digest is available. needs: [fast-gate, preview-amd64] if: | !cancelled() && needs.fast-gate.result == 'success' && + needs.preview-amd64.result != 'failure' && needs.preview-amd64.outputs.digest == '' && ( github.event_name == 'merge_group' || (github.event_name == 'push' && github.ref == 'refs/heads/release/v2') || @@ -115,10 +114,9 @@ jobs: (github.event_name == 'workflow_dispatch' && (inputs.operation == 'validate' || inputs.operation == 'publish')) ) runs-on: ubuntu-latest - timeout-minutes: 30 + timeout-minutes: 20 permissions: contents: read - packages: read steps: - uses: actions/checkout@v7 if: "!env.ACT" @@ -126,23 +124,10 @@ jobs: persist-credentials: false ref: ${{ github.event_name == 'workflow_dispatch' && inputs.ref || '' }} - - name: Setup - uses: ./tooling/github/setup - - name: Set up Docker Buildx - if: "!env.ACT" + if: needs.preview-amd64.outputs.digest == '' && !env.ACT uses: docker/setup-buildx-action@v4 - - name: Reuse amd64 preview image - if: needs.preview-amd64.outputs.digest != '' && !env.ACT - env: - DIGEST: ${{ needs.preview-amd64.outputs.digest }} - run: | - echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin - docker pull "${PREVIEW_IMAGE}@${DIGEST}" - docker tag "${PREVIEW_IMAGE}@${DIGEST}" homarr-ci - docker tag "${PREVIEW_IMAGE}@${DIGEST}" homarr-e2e - - name: Build container once if: needs.preview-amd64.outputs.digest == '' && !env.ACT uses: docker/build-push-action@v7 @@ -152,9 +137,7 @@ jobs: platforms: linux/amd64 load: true push: false - tags: | - homarr-ci - homarr-e2e + tags: homarr-ci labels: | org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }} org.opencontainers.image.revision=${{ github.sha }} @@ -182,37 +165,7 @@ jobs: fi done DOCKER_BUILDKIT=1 docker build --platform linux/amd64 \ - --tag homarr-ci --tag homarr-e2e "${secret_args[@]}" . - - - name: Cache Playwright browsers - if: "!env.ACT" - id: playwright-cache - uses: actions/cache@v6 - with: - path: ~/.cache/ms-playwright - key: playwright-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }} - - - name: Install Playwright Chromium - if: steps.playwright-cache.outputs.cache-hit != 'true' || env.ACT - run: pnpm exec playwright install --with-deps chromium - - - name: Install Playwright system deps - if: steps.playwright-cache.outputs.cache-hit == 'true' - run: pnpm exec playwright install-deps chromium - - - name: Run E2E tests - env: - NODE_ENV: development - CI: "true" - run: pnpm exec vitest e2e --retry 1 --maxWorkers 4 - - - name: Upload E2E artifacts - if: failure() && !env.ACT - uses: actions/upload-artifact@v7 - with: - name: playwright-report - path: e2e/test-results/ - retention-days: 7 + --tag homarr-ci "${secret_args[@]}" . preview-amd64: name: Build amd64 preview image @@ -320,10 +273,10 @@ jobs: publish-preview: name: Publish multi-platform preview image - needs: [container, preview-amd64, preview-arm64] + needs: [fast-gate, preview-amd64, preview-arm64] if: | !cancelled() && - needs.container.result == 'success' && + needs.fast-gate.result == 'success' && needs.preview-amd64.result == 'success' && needs.preview-arm64.result == 'success' && ( diff --git a/.github/workflows/workshop.yml b/.github/workflows/workshop.yml index 88bfecd47..c10bd0a7f 100644 --- a/.github/workflows/workshop.yml +++ b/.github/workflows/workshop.yml @@ -2,8 +2,36 @@ name: Workshop on: pull_request: + paths: + - ".github/workflows/workshop.yml" + - "apps/workshop/**" + - "apps/docs/**" + - "packages/custom-widgets/**" + - "packages/definitions/**" + - "packages/workshop/**" + - "tooling/typescript/**" + - "patches/**" + - "package.json" + - "pnpm-lock.yaml" + - "pnpm-workspace.yaml" + - "turbo.json" + - ".npmrc" push: branches: [dev, v2, release/v2, main] + paths: + - ".github/workflows/workshop.yml" + - "apps/workshop/**" + - "apps/docs/**" + - "packages/custom-widgets/**" + - "packages/definitions/**" + - "packages/workshop/**" + - "tooling/typescript/**" + - "patches/**" + - "package.json" + - "pnpm-lock.yaml" + - "pnpm-workspace.yaml" + - "turbo.json" + - ".npmrc" permissions: contents: read @@ -15,21 +43,10 @@ concurrency: jobs: validate: runs-on: ubuntu-24.04 - timeout-minutes: 30 + timeout-minutes: 5 steps: - uses: actions/checkout@v7 - - uses: actions/setup-node@v6 - with: - node-version: 24.18.0 - - uses: pnpm/action-setup@v6 - with: - run_install: false - - run: pnpm install --frozen-lockfile - - run: pnpm --filter @homarr/workshop typecheck - - run: pnpm --filter @homarr/docs typecheck - run: docker compose --env-file apps/workshop/.env.staging.example -f apps/workshop/docker-compose.staging.yml config --quiet - - run: pnpm test:workshop - - run: pnpm test:workshop-image publish-amd64: if: github.event_name == 'push' && (github.ref_name == 'v2' || github.ref_name == 'release/v2' || github.ref_name == 'main') diff --git a/apps/docs/docs/advanced/development/getting-started.mdx b/apps/docs/docs/advanced/development/getting-started.mdx index 1ab74a197..0e69e83e0 100644 --- a/apps/docs/docs/advanced/development/getting-started.mdx +++ b/apps/docs/docs/advanced/development/getting-started.mdx @@ -82,3 +82,25 @@ Workshop contributors can set up PocketBase and Docusaurus with the dedicated - `pnpm build` — production monorepo build. - `docker build -t homarr .` — build a production Docker image. - `docker run -p 7575:7575 -e SECRET_ENCRYPTION_KEY='your_64_character_hex_string' homarr:` — run it. + +## CI and focused validation + +The main CI workflow runs lint, typechecking, affected workspace builds, workspace configuration checks, OpenAPI validation, and Custom Widget architecture and bundle checks. Unit and E2E suites do not run in CI. Preview images publish after the Fast gate and both architecture builds succeed; browser tests do not block publication. The container job builds an amd64 image when no preview image was built. + +Workshop CI runs only when its workflow or Docker build inputs change. It validates Compose configuration and publishes production images on its configured release branches. Its Docker integration and image tests are manual; workspace typechecks run in the main Fast gate. + +Choose the smallest relevant suite when changing behavior: + +| Command | Scope | Requirements | +| ------------------------------------------------- | -------------------------------------------------------------- | --------------------------------------------------------------------- | +| `pnpm test ` | Focused unit or contract tests | Workspace dependencies | +| `pnpm test` | All unit and contract tests, without Docker integration suites | Workspace dependencies | +| `pnpm test:coverage` | Unit and contract tests with coverage | Workspace dependencies | +| `pnpm test:integration ` | Docker-backed service and database compatibility tests | Docker | +| `pnpm test:e2e ` | Browser and container scenarios | Docker; Playwright Chromium and system dependencies for browser tests | +| `pnpm test:docs-screenshots` | Regenerate Assistant documentation screenshots | Same browser prerequisites | +| `pnpm test:workshop` / `pnpm test:workshop-image` | Workshop integration / production-image validation | Docker | + +Omit `` to run all tests in that command's scope. E2E and screenshot commands use a local image tagged `homarr-e2e`, or an image selected with `HOMARR_E2E_IMAGE`. The default commands run once without automatic retries; coverage is opt-in. The screenshot generator is excluded from both unit and E2E commands. + +Keep behavioral coverage for authorization, migrations, persistence, and widget execution. Avoid tests that only search source code for particular component names, JSX, CSS, or documentation wording. diff --git a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_copy-ai-prompt-button.spec.tsx b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_copy-ai-prompt-button.spec.tsx index 3ad283ea4..d390e3cd4 100644 --- a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_copy-ai-prompt-button.spec.tsx +++ b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_copy-ai-prompt-button.spec.tsx @@ -1,43 +1,12 @@ -import { readFileSync } from "node:fs"; import { describe, expect, it } from "vitest"; import { buildCustomWidgetAiPrompt } from "@homarr/custom-widgets/authoring-prompt"; describe("Copy AI prompt", () => { - it("uses one self-contained prompt without an embedded-skill branch", () => { - const source = readFileSync( - `${process.cwd()}/apps/nextjs/src/app/[locale]/manage/custom-widgets/_copy-ai-prompt-button.tsx`, - "utf8", - ); - expect(source).toContain("buildCustomWidgetAiPrompt"); - expect(source).not.toContain("embedded-authoring-prompt"); - expect(source).not.toContain("Menu"); - }); - it("copies a complete prompt within the public size limit", () => { const prompt = buildCustomWidgetAiPrompt(undefined, null, null, "Create a Pokédex"); expect(prompt.length).toBeLessThanOrEqual(12_000); expect(prompt).toContain("exactly one complete `json` fenced block"); expect(prompt).toContain("Put the complete JSX source directly in the `template` string"); }); - - it("keeps AI actions and section navigation readable on narrow screens", () => { - const card = readFileSync( - `${process.cwd()}/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-ai-card.tsx`, - "utf8", - ); - const styles = readFileSync( - `${process.cwd()}/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-form.module.css`, - "utf8", - ); - const actions = readFileSync( - `${process.cwd()}/apps/nextjs/src/app/[locale]/manage/custom-widgets/_copy-ai-prompt-button.tsx`, - "utf8", - ); - expect(card).toContain(''); - expect(card).not.toContain("defaultValue"); - expect(actions).toContain(" a"); - expect(styles).toContain("flex: 0 0 auto"); - }); }); diff --git a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-legacy-migration.spec.ts b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-legacy-migration.spec.ts deleted file mode 100644 index 10e2def28..000000000 --- a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-legacy-migration.spec.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { readFileSync } from "node:fs"; -import { describe, expect, it } from "vitest"; - -const readSource = (path: string) => readFileSync(`${process.cwd()}/${path}`, "utf8"); - -describe("legacy custom widget migration UI", () => { - it("keeps legacy definitions visible and exposes the redacted LLM workflow", () => { - const list = readSource("apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-list.tsx"); - const actions = readSource("apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-actions.tsx"); - - expect(list).toContain("widget.migrationRequired"); - expect(list).toContain('t("page.list.migrationInstructions")'); - expect(actions).toContain("legacyMigrationPrompt.fetch"); - expect(actions).toContain("navigator.clipboard.writeText(result.prompt)"); - expect(actions).toContain("navigator.clipboard.readText()"); - expect(actions).toContain('t("action.pasteMigration")'); - expect(actions).toContain("parseCustomWidgetClipboardDetailed"); - expect(actions).toContain("legacyId={widget.id}"); - }); - - it("replaces the legacy record through migration and refreshes every consumer", () => { - // The import pipeline is shared by the dialog and the Workshop install page. - const importer = readSource("apps/nextjs/src/components/custom-widgets/use-custom-widget-import.ts"); - const dialog = readSource("apps/nextjs/src/components/custom-widgets/custom-widget-import-dialog.tsx"); - - expect(importer).toContain("customWidget.migrateLegacy.useMutation"); - expect(importer).toMatch( - /migrateMutation\.mutate\(\{\s*id: legacyId,\s*widget: configuredWidget,\s*secrets,?\s*\}\)/u, - ); - expect(importer).toContain("utils.customWidget.list.invalidate()"); - expect(importer).toContain("utils.customWidget.available.invalidate()"); - expect(importer).toContain("utils.widget.customApi.getData.invalidate()"); - expect(dialog).toContain("useCustomWidgetImport({"); - expect(dialog).toContain("legacyId,"); - }); -}); diff --git a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-preview-panel.spec.tsx b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-preview-panel.spec.tsx index 503ed3f3c..6f2ef93c9 100644 --- a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-preview-panel.spec.tsx +++ b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-preview-panel.spec.tsx @@ -1,6 +1,5 @@ // @vitest-environment jsdom -import { readFileSync } from "node:fs"; import { act } from "react"; import { createRoot } from "react-dom/client"; import type { Root } from "react-dom/client"; @@ -76,16 +75,6 @@ afterEach(async () => { }); describe("Custom Widget preview panel", () => { - test("inherits the application theme instead of forcing a reload-sensitive preview theme", () => { - const source = readFileSync( - `${process.cwd()}/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-preview-panel.tsx`, - "utf8", - ); - - expect(source).not.toContain("forceColorScheme"); - expect(source).not.toContain("onThemeChange"); - }); - test("contains invalid definitions inside the widget canvas while keeping every inspection tab", async () => { await act(async () => { root.render( diff --git a/apps/nextjs/src/components/navigation/dynamic-breadcrumb.spec.ts b/apps/nextjs/src/components/navigation/dynamic-breadcrumb.spec.ts deleted file mode 100644 index 48bd4f50b..000000000 --- a/apps/nextjs/src/components/navigation/dynamic-breadcrumb.spec.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { readFileSync } from "node:fs"; -import { describe, expect, it } from "vitest"; - -describe("DynamicBreadcrumb", () => { - it("falls back to the raw segment when a path segment has no translation", () => { - const source = readFileSync( - `${process.cwd()}/apps/nextjs/src/components/navigation/dynamic-breadcrumb.tsx`, - "utf8", - ); - - // Dynamic ids (e.g. /manage/custom-widgets/workshop/) have no message and are - // not mapped while their page loads; resolving them must not raise MISSING_MESSAGE. - expect(source).toContain("let label = pathnamePart"); - expect(source).toContain("if (mappedValue !== undefined)"); - expect(source).toContain("t.has(labelKey)"); - expect(source).not.toMatch(/\{t\(`\$\{translationKey\}\.label`/u); - }); -}); diff --git a/apps/nextjs/src/components/workshop/workshop-install-flow.spec.ts b/apps/nextjs/src/components/workshop/workshop-install-flow.spec.ts index a0f1fc734..180082088 100644 --- a/apps/nextjs/src/components/workshop/workshop-install-flow.spec.ts +++ b/apps/nextjs/src/components/workshop/workshop-install-flow.spec.ts @@ -1,18 +1,9 @@ // @vitest-environment jsdom -import { readFileSync } from "node:fs"; import { afterEach, describe, expect, it } from "vitest"; import { getWorkshopAssistantProviderUrl, getWorkshopWebUrl } from "./workshop-client"; -const read = (path: string) => readFileSync(`${process.cwd()}/${path}`, "utf8"); - -const detailPage = "apps/nextjs/src/app/[locale]/manage/custom-widgets/workshop/[id]/_workshop-detail.tsx"; -const browsePage = "apps/nextjs/src/app/[locale]/manage/custom-widgets/workshop/_workshop-browse.tsx"; -const browseRoutePage = "apps/nextjs/src/app/[locale]/manage/custom-widgets/workshop/page.tsx"; -const publishPage = "apps/nextjs/src/app/[locale]/manage/custom-widgets/publish/[id]/_workshop-publish-form.tsx"; -const cssButton = "apps/nextjs/src/components/workshop/workshop-css-import-button.tsx"; - describe("Workshop widget install flow", () => { afterEach(() => { document.head.innerHTML = ""; @@ -39,136 +30,4 @@ describe("Workshop widget install flow", () => { expect(getWorkshopAssistantProviderUrl()).toBe("https://api.preview.example/api/ai/v1"); }); - - it("installs from a page with a sticky footer instead of stacked modals", () => { - const detail = read(detailPage); - - expect(detail).toContain("ManageStickyFooter"); - expect(detail).toContain("useCustomWidgetImport"); - // Review and install happen on one surface, so neither Modal.Stack nor the review dialog is involved. - expect(detail).not.toContain("Modal.Stack"); - expect(detail).not.toContain("useModalsStack"); - expect(detail).not.toContain("CustomWidgetImportDialog"); - }); - - it("browses the Workshop as a card grid with per-item Workshop links", () => { - const browse = read(browsePage); - const route = read(browseRoutePage); - - expect(browse).toContain("WorkshopSubmissionGrid"); - expect(browse).toContain("SearchInput"); - expect(browse).toContain("TablePagination"); - expect(browse).toContain("CustomWidgetTabs"); - expect(route).toContain("resolveHomarrUrlConfig"); - expect(route).toContain("workshopWebUrl={workshopWebUrl}"); - expect(browse).toContain("workshopWebUrl: string"); - expect(browse).toContain("href={workshopWebUrl}"); - expect(browse).toContain('workshopWebUrl.replace(/\\/+$/u, "")'); - expect(browse).toContain("encodeURIComponent(item.id)"); - expect(browse).toContain("WorkshopVoteControl"); - expect(browse).not.toContain("Modal"); - }); - - it("renders the grid with real cards and screenshot previews", () => { - const grid = read("apps/nextjs/src/components/workshop/workshop-submission-grid.tsx"); - const card = read("apps/nextjs/src/components/workshop/workshop-submission-card.tsx"); - - expect(grid).toContain("SimpleGrid"); - expect(card).toContain("Card.Section"); - expect(card).toContain("PREVIEW_HEIGHT"); - }); - - it("shows the viewer's own vote as a filled control", () => { - const vote = read("apps/nextjs/src/components/workshop/workshop-vote-control.tsx"); - - expect(vote).toContain("useWorkshopUserVotesQuery"); - expect(vote).toContain("IconArrowBigUpFilled"); - expect(vote).toContain("IconArrowBigDownFilled"); - expect(vote).toContain('color={upvoted ? "red" : "gray"}'); - expect(vote).toContain("aria-pressed={upvoted}"); - }); - - it("previews screenshots large with a zoom lightbox", () => { - const screenshots = read("apps/nextjs/src/components/workshop/workshop-screenshots.tsx"); - const detail = read(detailPage); - - expect(screenshots).toContain("1200x800"); - expect(screenshots).toContain("zoomScreenshot"); - expect(screenshots).toContain('import { Lightbox } from "@mantine/lightbox"'); - expect(screenshots).toContain("withThumbnails"); - expect(screenshots).toContain("withZoom"); - expect(screenshots).toContain("withKeyboardEvents"); - expect(screenshots).not.toMatch(/ { - const publish = read(publishPage); - - expect(publish).toContain("ManageStickyFooter"); - expect(publish).not.toContain("useModalsStack"); - expect(publish).not.toMatch(/ { - const css = read(cssButton); - - expect(css).toContain("WorkshopSubmissionGrid"); - expect(css).toContain("useConfirmModal"); - expect(css).not.toContain("useModalsStack"); - expect(css).not.toContain("Modal.Stack"); - }); - - it("cannot import the same widget twice and returns to the installed list", () => { - const importer = read("apps/nextjs/src/components/custom-widgets/use-custom-widget-import.ts"); - const detail = read(detailPage); - - // The mutation creates a new record per call, so a second click must be impossible. - expect(importer).toContain("if (!configuredWidget || pending || succeeded) return;"); - expect(importer).toContain("setSucceeded(true)"); - expect(detail).toContain("router.push(installedHref)"); - expect(detail).toContain("disabled={!compatible || !importer.ready || importer.succeeded}"); - // The heavy edit form is no longer the post-install destination. - expect(detail).not.toContain("/manage/custom-widgets/edit/"); - }); - - it("keeps typed credentials across a background refetch of the submission", () => { - const importer = read("apps/nextjs/src/components/custom-widgets/use-custom-widget-import.ts"); - const detail = read(detailPage); - - // Voting invalidates the detail query. If the setup reset keyed off the widget - // object identity, the refetch would silently clear part-typed API keys. - expect(importer).toContain("const sourcesKey = JSON.stringify(widget?.sources ?? {})"); - expect(importer).toContain("[sourcesKey]"); - expect(importer).not.toContain("getCustomWidgetSourceSetups(widget?.sources ?? {}), [widget])"); - expect(detail).toContain("const content = detail.data?.content;"); - expect(detail).toContain("[content]"); - }); - - it("keeps the sign-in hint reachable when voting is unavailable", () => { - const vote = read("apps/nextjs/src/components/workshop/workshop-vote-control.tsx"); - - // Mantine tooltips do not fire on disabled controls, so the hint hangs off the wrapper. - expect(vote).toContain('canVote ? controls : {controls}'); - }); - - it("cannot publish the same widget twice", () => { - const publish = read(publishPage); - - expect(publish).toContain("if (createSubmission.isPending) return;"); - expect(publish).toContain("createSubmission.isPending ||"); - }); - - it("no longer ships the stacked-modal install components", () => { - for (const removed of [ - "apps/nextjs/src/components/workshop/workshop-browser.tsx", - "apps/nextjs/src/components/workshop/workshop-install-button.tsx", - "apps/nextjs/src/components/workshop/workshop-installer.tsx", - "apps/nextjs/src/components/workshop/workshop-publish-modal.tsx", - "apps/nextjs/src/components/workshop/workshop-submission-list.tsx", - "apps/nextjs/src/components/workshop/workshop-submission-item.tsx", - ]) { - expect(() => read(removed)).toThrow(); - } - }); }); diff --git a/apps/workshop/README.md b/apps/workshop/README.md index f4cf84c0f..0e5b1928c 100644 --- a/apps/workshop/README.md +++ b/apps/workshop/README.md @@ -77,7 +77,7 @@ pnpm --filter @homarr/docs typecheck ``` The integration suite uses a disposable Compose project. `pnpm test:workshop-image` additionally builds and smoke-tests -the combined production image. +the combined production image. Both commands are manual and do not run in CI. Workshop CI validates Compose configuration only when its workflow or Docker build inputs change, then publishes images on release branches. The main Fast gate owns workspace typechecking. ## Data safety diff --git a/apps/workshop/tests/workshop-contracts.mjs b/apps/workshop/tests/workshop-contracts.mjs index f507b1d6e..d2a8399d7 100644 --- a/apps/workshop/tests/workshop-contracts.mjs +++ b/apps/workshop/tests/workshop-contracts.mjs @@ -1,48 +1,7 @@ -import { access, readFile } from "node:fs/promises"; -import { resolve } from "node:path"; +import { readFile } from "node:fs/promises"; import vm from "node:vm"; -const read = (path) => readFile(resolve(path), "utf8"); - -const packageJson = JSON.parse(await read("packages/workshop/package.json")); -const clientExport = packageJson.exports?.["./client"]; -if (typeof clientExport !== "string") throw new Error("Workshop client export is missing"); -await access(resolve("packages/workshop", clientExport)); - -const hook = await read("apps/workshop/pb_hooks/workshop.pb.js"); -if (!hook.includes("require(`${__hooks}/workshop-utils.js`)")) { - throw new Error("Workshop handlers must load shared helpers inside their isolated PocketBase contexts"); -} -if (!hook.includes("onBootstrap") || !hook.includes("users.oauth2.providers = configured")) { - throw new Error("Workshop OAuth settings must be synchronized at every bootstrap"); -} -if (!hook.includes('username: "name"') || hook.includes("onRecordAuthWithOAuth2Request")) { - throw new Error("Workshop identity must use PocketBase's direct GitHub username mapping"); -} -if (hook.includes("validateAndNormalizeSubmission")) { - throw new Error("PocketBase must store Workshop submissions without interpreting their content"); -} -for (const artifact of [ - "apps/workshop/pb_hooks/widget-validator.js", - "apps/workshop/pb_hooks/widget-validator.bundle.cjs", - "packages/workshop/src/pocketbase-validator.ts", - "scripts/build-workshop-validator.mjs", -]) { - await access(resolve(artifact)) - .then(() => { - throw new Error(`Workshop validator artifact must stay removed: ${artifact}`); - }) - .catch((error) => { - if (error instanceof Error && "code" in error && error.code === "ENOENT") return; - throw error; - }); -} -if (hook.includes('findAllRecords("users")') || !hook.includes('findRecordsByFilter("users"')) { - throw new Error("Workshop report notifications must use a filtered, bounded administrator query"); -} -if (!hook.includes('routerAdd("GET", "/workshop/{id}"') || !hook.includes('getStringSlice("screenshots")')) { - throw new Error("Workshop detail routes must render server-side social metadata with submission screenshots"); -} +const read = (path) => readFile(path, "utf8"); const hookUtilsModule = { exports: {} }; vm.runInNewContext(await read("apps/workshop/pb_hooks/workshop-utils.js"), { module: hookUtilsModule }); @@ -69,73 +28,4 @@ for (const expected of [ } if (socialHtml.includes("Homarr documentation")) throw new Error("Workshop social metadata must replace generic tags"); -const migration = await read("apps/workshop/pb_migrations/1784240000_workshop_widgets.js"); -for (const removedField of ["displayName", "avatarUrl", "githubProfileUrl", "githubUsername"]) { - if (migration.includes(removedField)) throw new Error(`Redundant Workshop user field remains: ${removedField}`); -} - -const providerMigration = await read("apps/workshop/pb_migrations/1786500000_homarr_provider.js"); -for (const required of ['name: "assistant_quotas"', 'name: "assistant_global_quota"', "idx_assistant_quotas_user"]) { - if (!providerMigration.includes(required)) throw new Error(`Homarr provider migration is missing: ${required}`); -} -for (const forbidden of ['name: "assistant_requests"', 'name: "assistant_activity"', 'name: "dailyLimit"']) { - if (providerMigration.includes(forbidden)) throw new Error(`Homarr provider must not persist ${forbidden}`); -} - -const dailyQuotaMigration = await read("apps/workshop/pb_migrations/1787670446_daily_assistant_quotas.js"); -for (const required of [ - 'removeIndex("idx_assistant_quotas_user")', - 'addIndex("idx_assistant_quotas_user_day", true, "user, day", "")', -]) { - if (!dailyQuotaMigration.includes(required)) throw new Error(`Daily quota migration is missing: ${required}`); -} - -const providerBackend = await read("apps/workshop/homarr_provider.go"); -for (const required of [ - '"homarr/model"', - 'apis.RequireAuth("users")', - "DefaultActivityLoggerMiddlewareId", - 'os.Getenv("OPENROUTER_API_KEY")', - 'os.Getenv("HOMARR_AI_DAILY_REQUEST_LIMIT")', - 'os.Getenv("HOMARR_AI_GLOBAL_DAILY_REQUEST_LIMIT")', - 'os.Getenv("HOMARR_AI_OPENROUTER_MODEL")', - '"data_collection": "deny"', - '"zdr": true', -]) { - if (!providerBackend.includes(required)) throw new Error(`Homarr provider backend is missing: ${required}`); -} -if (/logger\(\)|\.Logger\(|log\.Print/u.test(providerBackend)) { - throw new Error("The Homarr provider must not log request content or user tokens"); -} -for (const required of [ - "workshop_migration_state", - "addedUserFields", - "cloneRule(users.listRule)", - "CREATE TRIGGER submissions_revision_cas", - "NEW.expectedRevision != OLD.revision", - "users.passwordAuth = state.users.passwordAuth", - "users.oauth2 = state.users.oauth2", - "app.delete(users)", -]) { - if (!migration.includes(required)) throw new Error(`Workshop rollback is missing state restoration: ${required}`); -} - -const workflow = await read(".github/workflows/workshop.yml"); -if (/\n\s+paths:/u.test(workflow)) throw new Error("Workshop workflow must not filter out Docker build inputs"); - -const entrypoint = await read("apps/workshop/entrypoint.sh"); -if (!entrypoint.includes("/pb_public/workshop-runtime-config.js") || !entrypoint.includes("WORKSHOP_API_URL")) { - throw new Error("Workshop must publish its API URL when the container starts"); -} - -const docsConfig = await read("apps/docs/docusaurus.config.ts"); -if (!docsConfig.includes('scripts: [{ src: "/workshop-runtime-config.js" }]')) { - throw new Error("Workshop must load the container runtime configuration before the documentation bundle"); -} -if (!docsConfig.includes('path: "/workshop/:id"') || !docsConfig.includes("priority: -1")) { - throw new Error("Workshop submission routes must not capture the moderation page"); -} -await access(resolve("apps/docs/static/workshop-runtime-config.js")); -await access(resolve("apps/docs/src/pages/workshop/admin/index.tsx")); - -console.log("Workshop static contracts passed"); +console.log("Workshop social metadata rendering passed"); diff --git a/e2e/assistant-docs-screenshots.spec.ts b/e2e/assistant-docs-screenshots.spec.ts index fa672fb55..4ea20ad80 100644 --- a/e2e/assistant-docs-screenshots.spec.ts +++ b/e2e/assistant-docs-screenshots.spec.ts @@ -16,8 +16,7 @@ const adminCredentials = { password: "Comp(exP4sswOrd", }; -// This is an opt-in documentation asset generator, not part of the normal E2E gate. -// Run it explicitly with UPDATE_DOCS_SCREENSHOTS=true when the documented UI changes. +// Run this opt-in asset generator with pnpm test:docs-screenshots when the documented UI changes. describe.skipIf(process.env.UPDATE_DOCS_SCREENSHOTS !== "true")("Assistant documentation screenshots", () => { test("captures the management page and conversation panel", async () => { const { db, localMountPath } = await createSqliteDbFileAsync(); diff --git a/e2e/custom-widgets-custom-jsx-v2.spec.ts b/e2e/custom-widgets-custom-jsx-v2.spec.ts deleted file mode 100644 index d3ad132de..000000000 --- a/e2e/custom-widgets-custom-jsx-v2.spec.ts +++ /dev/null @@ -1,114 +0,0 @@ -import { chromium, expect } from "@playwright/test"; -import type { Browser } from "@playwright/test"; -import type { StartedTestContainer } from "testcontainers"; -import { describe, test } from "vitest"; - -import { createHomarrContainer } from "./shared/create-homarr-container"; -import { createSqliteDbFileAsync } from "./shared/e2e-db"; -import type { MockApiServer } from "./shared/mock-api-server"; -import { startMockApiServerAsync } from "./shared/mock-api-server"; -import { seedAdminUserAsync } from "./shared/seed-admin-user"; - -const adminCredentials = { username: "admin", password: "Comp(exP4sswOrd" }; -const initialTemplate = - "{data.status?.title}{data.status?.status}"; -const updatedTemplate = "{data.status?.title}{data.status?.value}"; - -describe("Custom JSX v2 workbench", () => { - test.skip("creates, previews, edits, and immediately adds a widget to a board", async () => { - let mockApi: MockApiServer | undefined; - let homarrContainer: StartedTestContainer | undefined; - let browser: Browser | undefined; - - try { - mockApi = await startMockApiServerAsync({ title: "E2E Widget", status: "online", value: 42 }); - const { db, localMountPath } = await createSqliteDbFileAsync(); - await seedAdminUserAsync(db, adminCredentials); - homarrContainer = await createHomarrContainer({ - environment: { AUTH_PROVIDERS: "credentials" }, - mounts: { "/appdata": localMountPath }, - }).start(); - const baseUrl = `http://${homarrContainer.getHost()}:${homarrContainer.getMappedPort(7575)}`; - browser = await chromium.launch(); - const page = await browser.newPage(); - - await page.goto(`${baseUrl}/auth/login`); - await page.waitForLoadState("networkidle"); - await page.getByLabel("Username").fill(adminCredentials.username); - await page.locator("#password").fill(adminCredentials.password); - const signedIn = page.waitForURL((url) => url.origin === baseUrl && url.pathname !== "/auth/login", { - waitUntil: "commit", - timeout: 60_000, - }); - await page.locator("button[type='submit']").click(); - await signedIn; - - await page.goto(`${baseUrl}/manage/custom-widgets/new`); - await page.locator('input[data-path="name"]:visible').fill("E2E Custom JSX v2"); - await page.getByRole("textbox", { name: "Base URL" }).fill(mockApi.url); - await page.getByRole("combobox", { name: "Network access" }).click(); - await page.getByRole("option", { name: "private" }).click(); - await page.getByRole("group", { name: "status" }).getByRole("textbox", { name: "Path" }).fill("/status"); - await page.getByRole("button", { name: "Add option" }).click(); - const option = page.getByRole("group", { name: "Option 1" }); - await option.getByRole("textbox", { name: "Option name" }).fill("label"); - await option.getByRole("textbox", { name: "Default" }).fill("E2E"); - const request = page.getByRole("group", { name: "status" }); - await request.getByRole("button", { name: "Query, body and behavior" }).click(); - await request.getByRole("button", { name: "Add query value" }).click(); - await request.getByRole("textbox", { name: "Query key" }).fill("label"); - await request.getByRole("combobox", { name: "Value source" }).click(); - await page.getByRole("option", { name: "Widget option" }).click(); - await request.getByRole("combobox", { name: "Option" }).first().click(); - await page.getByRole("option", { name: "label" }).click(); - await page.getByRole("button", { name: "Advanced manifest JSON" }).first().click(); - await expect(page.locator("#requests-editor")).toContainText('"$option": "label"'); - await page.locator("#jsx-editor").fill(initialTemplate); - - await page.getByRole("button", { name: "Test and preview" }).last().click(); - await expect(page.getByRole("complementary", { name: "Widget preview" }).getByText("E2E Widget")).toBeVisible({ - timeout: 15_000, - }); - - const editPage = page.waitForURL("**/manage/custom-widgets/edit/**", { timeout: 15_000, waitUntil: "commit" }); - await page.getByRole("button", { name: "Create", exact: true }).last().click(); - await editPage; - - await page.locator("#jsx-editor").fill(updatedTemplate); - await page.getByRole("button", { name: "Save", exact: true }).last().click(); - await expect(page.getByText('Widget "E2E Custom JSX v2" updated successfully.')).toBeVisible({ timeout: 15_000 }); - - await page.goto(baseUrl); - const tourOverlay = page.locator('[data-onboarding-tour-overlay="true"]'); - const skipTour = page.getByRole("button", { name: "Skip tour", exact: true }); - try { - await skipTour.waitFor({ state: "visible", timeout: 5_000 }); - await skipTour.click(); - await expect(tourOverlay).toHaveCount(0); - } catch (error) { - if ((await tourOverlay.count()) > 0) throw error; - } - const editToggle = page.getByTestId("board-edit-mode-toggle"); - await editToggle.click(); - await expect(editToggle).toHaveAttribute("aria-pressed", "true", { timeout: 15_000 }); - await page.getByRole("button", { name: "Add board content", exact: true }).click(); - await page - .getByRole("dialog", { name: "Create" }) - .getByRole("button", { name: /^Add widget / }) - .click(); - const picker = page.getByRole("dialog").last(); - await picker.getByRole("button", { name: /^E2E Custom JSX v2,/ }).click(); - const addDialog = page.getByRole("dialog").last(); - await expect(addDialog.getByText("E2E Custom JSX v2")).toBeVisible(); - await addDialog.getByRole("button", { name: "Save changes", exact: true }).click(); - - await expect(page.getByText("Widget definition not found")).not.toBeVisible(); - await editToggle.click(); - await expect(editToggle).toHaveAttribute("aria-pressed", "false", { timeout: 15_000 }); - await expect(page.getByText("E2E Widget")).toBeVisible({ timeout: 15_000 }); - await expect(page.getByText("42")).toBeVisible({ timeout: 15_000 }); - } finally { - await Promise.allSettled([browser?.close(), homarrContainer?.stop(), mockApi?.close()]); - } - }, 180_000); -}); diff --git a/e2e/home.spec.ts b/e2e/home.spec.ts deleted file mode 100644 index dcc8685c9..000000000 --- a/e2e/home.spec.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { describe, expect, test } from "vitest"; - -import { createHomarrContainer } from "./shared/create-homarr-container"; - -describe("Home", () => { - test("should open with status code 200", async () => { - // Arrange - const homarrContainer = await createHomarrContainer().start(); - - try { - // Act - const homeResponse = await fetch(`http://localhost:${homarrContainer.getMappedPort(7575)}/`); - - // Assert - expect(homeResponse.status).toBe(200); - } finally { - await homarrContainer.stop(); - } - }, 60_000); -}); diff --git a/e2e/onboarding.spec.ts b/e2e/onboarding.spec.ts index 7f1d3b9b0..47c61f721 100644 --- a/e2e/onboarding.spec.ts +++ b/e2e/onboarding.spec.ts @@ -1,11 +1,9 @@ -import { mkdir, readFile, rm } from "node:fs/promises"; +import { mkdir, rm } from "node:fs/promises"; import path from "node:path"; import { chromium } from "@playwright/test"; import type { Page } from "@playwright/test"; import { createId } from "@paralleldrive/cuid2"; -import AdmZip from "adm-zip"; -import type { StartedTestContainer } from "testcontainers"; import { describe, expect, test } from "vitest"; import * as sqliteSchema from "../packages/db/schema/sqlite"; @@ -30,48 +28,6 @@ const cleanupSqliteDbAsync = async ({ db, localMountPath }: SqliteTestDatabase) await rm(localMountPath, { recursive: true, force: true }); }; -const createRestoreArchiveAsync = async () => { - const sqlite = await createSqliteDbFileAsync(); - const { db, localMountPath } = sqlite; - try { - await db.insert(sqliteSchema.onboarding).values({ id: "onboarding", step: "finish", previousStep: "setup" }); - await db.insert(sqliteSchema.boards).values({ id: "restored-board", name: "restored", isPublic: true }); - await db.insert(sqliteSchema.layouts).values({ - id: "restored-base", - boardId: "restored-board", - name: "Base", - role: "base", - columnCount: 10, - breakpoint: 768, - }); - await db.insert(sqliteSchema.sections).values({ - id: "restored-root", - boardId: "restored-board", - kind: "empty", - xOffset: 0, - yOffset: 0, - }); - db.$client.close(); - - const zip = new AdmZip(); - zip.addFile("db.sqlite", await readFile(path.join(localMountPath, "db", "db.sqlite"))); - zip.addFile( - "metadata.json", - Buffer.from( - JSON.stringify({ - homarrVersion: "e2e", - exportedAt: new Date().toISOString(), - dbDialect: "sqlite", - encryptionKey: "0".repeat(64), - }), - ), - ); - return zip.toBuffer(); - } finally { - await cleanupSqliteDbAsync(sqlite); - } -}; - describe("Onboarding", () => { test.each([ { name: "mobile", width: 320, height: 740 }, @@ -180,61 +136,6 @@ describe("Onboarding", () => { 90_000, ); - test.skip("SQLite onboarding restores a validated backup and survives the restart", async () => { - const restoreArchive = await createRestoreArchiveAsync(); - const invalidArchive = new AdmZip(); - invalidArchive.addFile("db.sqlite", Buffer.from("not-a-database")); - const sqlite = await createSqliteDbFileAsync(); - const { localMountPath } = sqlite; - const initialContainer = await createHomarrContainer({ mounts: { "/appdata": localMountPath } }).start(); - let initialContainerStopped = false; - let restoredContainer: StartedTestContainer | undefined; - const browser = await chromium.launch(); - const context = await browser.newContext(); - const page = await context.newPage(); - - try { - const initialBaseUrl = `http://${initialContainer.getHost()}:${initialContainer.getMappedPort(7575)}`; - await page.goto(`${initialBaseUrl}/init`); - await page.getByRole("button", { name: "Restore backup" }).click(); - const fileInput = page.locator('input[type="file"]'); - await fileInput.setInputFiles({ - name: "missing-metadata.zip", - mimeType: "application/zip", - buffer: invalidArchive.toBuffer(), - }); - await page.getByText("Invalid backup: missing metadata.json").waitFor(); - await page.getByRole("button", { name: "Change file" }).click(); - - await fileInput.setInputFiles({ name: "homarr-backup.zip", mimeType: "application/zip", buffer: restoreArchive }); - await page.getByRole("heading", { name: "Backup Contents" }).waitFor({ timeout: 30_000 }); - expect(await page.getByText("restored", { exact: true }).isVisible()).toBe(true); - await page.getByRole("button", { name: "Continue to restore" }).click(); - await page.getByPlaceholder("I understand").fill("I understand"); - const restoreResponse = page.waitForResponse( - (response) => response.url().endsWith("/api/backup/import") && response.request().method() === "POST", - ); - await page.getByRole("button", { name: "Restore Database" }).click(); - expect((await restoreResponse).status()).toBe(200); - - await initialContainer.stop(); - initialContainerStopped = true; - restoredContainer = await createHomarrContainer({ mounts: { "/appdata": localMountPath } }).start(); - const restoredBaseUrl = `http://${restoredContainer.getHost()}:${restoredContainer.getMappedPort(7575)}`; - await page.goto(`${restoredBaseUrl}/boards/restored`); - await page.locator('[data-testid="board-canvas"][data-board-hydrated="true"]').waitFor({ timeout: 30_000 }); - expect(page.url()).toMatch(/\/boards\/restored(?:[/?#]|$)/); - } finally { - try { - await browser.close(); - if (!initialContainerStopped) await initialContainer.stop(); - if (restoredContainer) await restoredContainer.stop(); - } finally { - await cleanupSqliteDbAsync(sqlite); - } - } - }, 120_000); - test("Credentials onboarding recovers when automatic sign-in fails after account creation", async () => { const sqlite = await createSqliteDbFileAsync(); const { db, localMountPath } = sqlite; diff --git a/e2e/shared/mock-api-server.ts b/e2e/shared/mock-api-server.ts deleted file mode 100644 index 10ccbcaab..000000000 --- a/e2e/shared/mock-api-server.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { createServer } from "node:http"; -import { getContainerRuntimeClient, TestContainers } from "testcontainers"; - -import type { Server } from "node:http"; - -const TESTCONTAINERS_SSHD_LABEL = "org.testcontainers.sshd"; - -export interface MockApiServer { - url: string; - close: () => Promise; -} - -export async function startMockApiServerAsync(responseBody: unknown): Promise { - const body = JSON.stringify(responseBody); - const server: Server = await new Promise((resolve) => { - const candidate = createServer((_request, response) => { - response.writeHead(200, { "content-type": "application/json" }); - response.end(body); - }); - candidate.listen(0, "0.0.0.0", () => resolve(candidate)); - }); - const address = server.address(); - if (!address || typeof address === "string") { - server.close(); - throw new Error("Failed to start mock API server"); - } - - try { - await TestContainers.exposeHostPorts(address.port); - const client = await getContainerRuntimeClient(); - const forwarder = (await client.container.list()).find( - (container) => container.State === "running" && container.Labels[TESTCONTAINERS_SSHD_LABEL] === "true", - ); - const forwarderAddress = forwarder - ? Object.values(forwarder.NetworkSettings.Networks).find((network) => network.IPAddress)?.IPAddress - : undefined; - if (!forwarderAddress) throw new Error("Failed to locate the Testcontainers host-port forwarder"); - - return { - url: `http://${forwarderAddress}:${address.port}`, - close: () => - new Promise((resolve, reject) => { - server.close((error) => (error ? reject(error) : resolve())); - }), - }; - } catch (error) { - server.close(); - throw error; - } -} diff --git a/package.json b/package.json index 8dba6107a..0c93400ef 100644 --- a/package.json +++ b/package.json @@ -49,12 +49,15 @@ "scripts:update-openapi": "tsx ./scripts/check-openapi-schema.mts --write", "scripts:update-readme-integrations": "tsx ./scripts/update-integration-list.mts", "start": "pnpm -F nextjs start", - "test": "cross-env NODE_ENV=development CI=true vitest run --project \"!e2e\" --retry 2 --coverage.enabled ", - "test:e2e": "cross-env NODE_ENV=development CI=true vitest e2e --retry 2 --maxWorkers 2", + "test": "cross-env NODE_ENV=development CI=true vitest run --project \"!e2e\" --project \"!integration\" --project \"!docs-screenshots\"", + "test:coverage": "pnpm test --coverage.enabled", + "test:integration": "cross-env NODE_ENV=development CI=true vitest run --project integration --maxWorkers 2", + "test:docs-screenshots": "cross-env UPDATE_DOCS_SCREENSHOTS=true NODE_ENV=development CI=true vitest run --project docs-screenshots", + "test:e2e": "cross-env NODE_ENV=development CI=true vitest run --project e2e --maxWorkers 2", "test:workshop": "sh apps/workshop/test.sh", "test:workshop-live-provider": "node apps/workshop/tests/openrouter-live-smoke.mjs", "test:workshop-image": "sh apps/workshop/test-image.sh", - "test:ui": "cross-env NODE_ENV=development CI=true vitest --project \"!e2e\" --ui --coverage.enabled", + "test:ui": "cross-env NODE_ENV=development CI=true vitest --project \"!e2e\" --project \"!integration\" --project \"!docs-screenshots\" --ui", "typecheck": "turbo typecheck", "with-env": "dotenv -e .env --" }, diff --git a/packages/custom-widgets/src/test/authoring-resources.spec.ts b/packages/custom-widgets/src/test/authoring-resources.spec.ts index 6b7218f83..3cdd619c4 100644 --- a/packages/custom-widgets/src/test/authoring-resources.spec.ts +++ b/packages/custom-widgets/src/test/authoring-resources.spec.ts @@ -37,12 +37,6 @@ describe("authoring resources", () => { expect(bundledContent).toContain(content.trim()); } expect(bundledContent).toContain(skill.skillMd.trim()); - expect(bundledContent).toContain("coordinated set of widgets"); - expect(bundledContent).toContain("customWidget_validateTemplate"); - expect(bundledContent).toContain("customWidget_findComponents"); - expect(bundledContent).toContain("Batch selected non-obvious binding or interaction documentation"); - expect(bundledContent).not.toContain("at most eight named component documents"); - expect(bundledContent).toContain("Do not use an unlabeled decorative icon as an empty state"); expect(Buffer.byteLength(JSON.stringify(skill), "utf8")).toBeLessThan(12_000); const entrypoint = getCustomWidgetSkillEntrypoint(); @@ -50,36 +44,7 @@ describe("authoring resources", () => { expect.arrayContaining([expect.objectContaining({ name: "runtime", tool: "customWidget_getReference" })]), ); expect(JSON.stringify(entrypoint)).not.toContain("# Runtime"); - expect(entrypoint.skillMd).toContain("Context routing"); - expect(entrypoint.skillMd).toContain("customWidget_validateTemplate"); - expect(entrypoint.skillMd).toContain("customWidget_previewReviseTemplate"); - expect(entrypoint.skillMd).toContain("map every requested capability"); - expect(entrypoint.skillMd).toContain("purpose-specific visual signature"); - expect(entrypoint.skillMd).toContain("asymmetric priority metric/action"); - expect(entrypoint.skillMd).toContain("new manifest"); - expect(entrypoint.skillMd).not.toContain("To install an existing widget"); - expect(getCustomWidgetSkillReference("runtime").content).toContain("SubFetch"); - expect(getCustomWidgetSkillReference("runtime").content).toContain( - 'require `trigger: "manual"` on request and `SubFetch`', - ); - expect(getCustomWidgetSkillReference("runtime").content).toContain("RefreshButton"); - expect(getCustomWidgetSkillReference("runtime").content).toContain("owns loading/error/retry"); - expect(getCustomWidgetSkillReference("runtime").content).toContain("need literal `requestId`"); - expect(getCustomWidgetSkillReference("runtime").content).toContain('requestId="search" label="Run again"'); - expect(getCustomWidgetSkillReference("runtime").content).toContain("Render load queries directly"); - expect(getCustomWidgetSkillReference("runtime").content).toContain("entire JSON response"); - expect(getCustomWidgetSkillReference("runtime").content).toContain("never map the envelope itself"); - expect(getCustomWidgetSkillReference("runtime").content).toContain("immediately hides its prior result"); - expect(getCustomWidgetSkillReference("runtime").content).toContain("Every stateful control must use `bind`"); - expect(getCustomWidgetSkillReference("runtime").content).toContain("resetKey={inputs.search}"); - expect(getCustomWidgetSkillReference("runtime").content).toContain('Date.toLocaleString(value, "en-US", "UTC")'); - expect(getCustomWidgetSkillReference("runtime").content).toContain("Never invent a formatter component"); const schemaReference = getCustomWidgetSkillReference("schema").content; - expect(schemaReference).toContain('"default": {'); - expect(schemaReference).toContain('"trigger": "manual"'); - expect(schemaReference).toContain('"kind": "action"'); - expect(schemaReference).toContain('"apiKeyHeader"'); - expect(schemaReference).toContain("is the required source ID"); expect(Buffer.byteLength(schemaReference, "utf8")).toBeLessThan(4_000); expect(Buffer.byteLength(JSON.stringify(entrypoint), "utf8")).toBeLessThan(3_000); expect(Buffer.byteLength(JSON.stringify(entrypoint), "utf8")).toBeLessThan( @@ -107,8 +72,6 @@ describe("authoring resources", () => { const serviceDashboard = getCustomWidgetExample("service-dashboard"); if (!serviceDashboard) throw new Error("Service dashboard example is missing"); expect(validateCustomJsxTemplate(serviceDashboard.widget.template)).toEqual([]); - expect(serviceDashboard.widget.template.match(/ { - test("defines the breadcrumb labels for the Custom Widgets sub-routes", async () => { - const messages = JSON.parse(await readFile(resolve(languageDirectory, "en.json"), "utf8")) as { - management?: { navbar?: { items?: { workshop?: unknown } } }; - navigationStructure?: { - manage?: { - workshop?: unknown; - "custom-widgets"?: { - workshop?: { label?: unknown }; - publish?: { label?: unknown }; - }; - }; - }; - customWidget?: { page?: { tabs?: Record } }; - workshop?: Record; - }; - - // The Workshop is reached through the Custom Widgets tabs, not its own navbar entry. - expect(messages.management?.navbar?.items?.workshop).toBeUndefined(); - expect(messages.navigationStructure?.manage?.workshop).toBeUndefined(); - expect(messages.navigationStructure?.manage?.["custom-widgets"]?.workshop?.label).toBe("Workshop"); - expect(messages.navigationStructure?.manage?.["custom-widgets"]?.publish?.label).toBe("Publish to Workshop"); - expect(messages.customWidget?.page?.tabs).toMatchObject({ - installed: "Installed", - workshop: "Workshop", - ariaLabel: expect.any(String), - }); - - expect(messages.workshop).toMatchObject({ - communityMember: "Community member", - outdated: "Outdated", - install: "Install", - back: expect.any(String), - listAriaLabel: expect.any(String), - installReviewTitle: expect.any(String), - emptyFilteredDescription: expect.any(String), - importCssConfirm: expect.any(String), - reportCount: expect.any(String), - reportWarning: expect.any(String), - reportVisibility: expect.any(String), - screenshotAlt: expect.any(String), - upvote: expect.any(String), - downvote: expect.any(String), - reportCategory: expect.objectContaining({ - outdated: expect.any(String), - malicious: expect.any(String), - spam: expect.any(String), - copyright: expect.any(String), - inappropriate: expect.any(String), - other: expect.any(String), - }), - publish: expect.objectContaining({ - invalidScreenshot: expect.any(String), - }), - }); - }); - - test("drops the message keys that only existed for the stacked install modals", async () => { - const messages = JSON.parse(await readFile(resolve(languageDirectory, "en.json"), "utf8")) as { - workshop?: Record; - }; - - for (const key of ["installDialog", "continueInstall", "confirmInstallTitle", "backToWidget", "inspectCss"]) { - expect(messages.workshop).not.toHaveProperty(key); - } - }); -}); diff --git a/vitest.config.mts b/vitest.config.mts index 8a47aa4b5..7552622ce 100644 --- a/vitest.config.mts +++ b/vitest.config.mts @@ -2,6 +2,20 @@ import react from "@vitejs/plugin-react"; import tsconfigPaths from "vite-tsconfig-paths"; import { configDefaults, defineConfig } from "vitest/config"; +// Docker-backed suites are opt-in through pnpm test:integration. +const integrationTests = [ + "**/*.integration.spec.ts", + "packages/db/test/mysql-migration.spec.ts", + "packages/db/test/postgresql-migration.spec.ts", + "packages/integrations/test/aria2.spec.ts", + "packages/integrations/test/home-assistant.spec.ts", + "packages/integrations/test/nextcloud.spec.ts", + "packages/integrations/test/nzbget.spec.ts", + "packages/integrations/test/pi-hole.spec.ts", + "packages/integrations/test/sabnzbd.spec.ts", + "packages/integrations/test/technitium.spec.ts", +]; + export default defineConfig({ plugins: [react(), tsconfigPaths()], test: { @@ -33,6 +47,7 @@ export default defineConfig({ environment: "node", setupFiles: ["./vitest.setup.ts", "./vitest.setup.node.ts"], include: ["packages/db/test/**/*.spec.ts"], + exclude: [...configDefaults.exclude, ...integrationTests], }, }, { @@ -77,15 +92,34 @@ export default defineConfig({ "packages/docker/**", "packages/request-handler/**", "e2e/**", + ...integrationTests, ], }, }, + { + extends: true, + test: { + name: "integration", + environment: "node", + setupFiles: ["./vitest.setup.ts", "./vitest.setup.node.ts"], + include: integrationTests, + }, + }, { extends: true, test: { name: "e2e", environment: "node", include: ["e2e/**/*.spec.ts"], + exclude: [...configDefaults.exclude, "e2e/assistant-docs-screenshots.spec.ts"], + }, + }, + { + extends: true, + test: { + name: "docs-screenshots", + environment: "node", + include: ["e2e/assistant-docs-screenshots.spec.ts"], }, }, ], From ea3dac752f4a656361219e08271b34111ad98905 Mon Sep 17 00:00:00 2001 From: Thomas Date: Thu, 10 Sep 2026 19:19:47 +0200 Subject: [PATCH 3/4] fix(mcp): harden tool schemas and dispatch (#6815) --- apps/docs/docs/management/mcp.mdx | 79 +++---- .../src/app/api/assistant/chat/route.ts | 16 +- .../src/app/api/mcp/[transport]/route.spec.ts | 28 ++- apps/nextjs/src/app/api/mcp/_extract-tools.ts | 9 +- apps/nextjs/src/app/api/mcp/_protocol.spec.ts | 202 +++++++++++++++++- apps/nextjs/src/app/api/mcp/_protocol.ts | 25 +-- packages/api/package.json | 2 + packages/api/src/mcp-tools.ts | 179 ++++++++++------ packages/api/src/mcp.ts | 2 +- packages/api/src/test/mcp.spec.ts | 136 +++++++++++- pnpm-lock.yaml | 9 + pnpm-workspace.yaml | 1 + 12 files changed, 531 insertions(+), 157 deletions(-) diff --git a/apps/docs/docs/management/mcp.mdx b/apps/docs/docs/management/mcp.mdx index 3a37ce9da..9cb7cfc40 100644 --- a/apps/docs/docs/management/mcp.mdx +++ b/apps/docs/docs/management/mcp.mdx @@ -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 AI / MCP management tab](./img/mcp.png) -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,29 +49,29 @@ 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. -Copy the complete generated API key, including both parts and the dot: `.`. Sending only the ID or token -returns `invalid_token`. Keep the full key private when sharing client configuration or diagnostics. +## Permissions and tools -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. +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. -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. +Custom Widget and Workshop authoring tools, prompts, and resources require administrator access. +For Custom Widgets, follow [Connect an agent](./custom-widgets/agent-authoring). +Secret values are configured in Homarr and are never returned through MCP. -Use HTTPS when the endpoint crosses an untrusted network. +The built-in [Assistant](./assistant) shares the MCP tool catalog, uses your Homarr session, and asks for approval +before mutations by default. -## Verify the endpoint +## Verify the connection -The 2025 compatibility path accepts a compact `tools/list` request: +This request lists tools through the older-client compatibility path. It checks authentication and tool discovery: ```sh curl -X POST \ @@ -80,25 +82,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. -Successful `server/discover` or `initialize` responses confirm connectivity, but do not validate the tool catalog. -Use `tools/list` above to check that the client can discover tools. If it returns -`Date cannot be represented in JSON Schema`, the server failed to generate a tool schema; changing the API key will -not resolve that error. Update Homarr to a release containing the invitation date schema fix. +## Troubleshooting -## Tools and resources - -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. - -The `invite_createInvite` tool accepts `expirationDate` as an ISO 8601 timestamp with a timezone, such as -`2026-12-01T18:00:00Z` or `2026-12-01T19:00:00+01:00`. - -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 `.` 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. diff --git a/apps/nextjs/src/app/api/assistant/chat/route.ts b/apps/nextjs/src/app/api/assistant/chat/route.ts index 6a9a08a1f..374ec4e4c 100644 --- a/apps/nextjs/src/app/api/assistant/chat/route.ts +++ b/apps/nextjs/src/app/api/assistant/chat/route.ts @@ -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( - (current, segment) => - (typeof current === "object" || typeof current === "function") && current !== null - ? (current as Record)[segment] - : undefined, - caller, - ); - if (typeof procedure !== "function") { - throw new Error("Procedure not callable"); - } - const result = await (procedure as (value: unknown) => Promise)( - 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), diff --git a/apps/nextjs/src/app/api/mcp/[transport]/route.spec.ts b/apps/nextjs/src/app/api/mcp/[transport]/route.spec.ts index 0debedc95..3e90c877f 100644 --- a/apps/nextjs/src/app/api/mcp/[transport]/route.spec.ts +++ b/apps/nextjs/src/app/api/mcp/[transport]/route.spec.ts @@ -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("../_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(); +}); diff --git a/apps/nextjs/src/app/api/mcp/_extract-tools.ts b/apps/nextjs/src/app/api/mcp/_extract-tools.ts index 5b90d2963..5fbe86fd1 100644 --- a/apps/nextjs/src/app/api/mcp/_extract-tools.ts +++ b/apps/nextjs/src/app/api/mcp/_extract-tools.ts @@ -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 | 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; } diff --git a/apps/nextjs/src/app/api/mcp/_protocol.spec.ts b/apps/nextjs/src/app/api/mcp/_protocol.spec.ts index b5c8882f8..a4b81acbb 100644 --- a/apps/nextjs/src/app/api/mcp/_protocol.spec.ts +++ b/apps/nextjs/src/app/api/mcp/_protocol.spec.ts @@ -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().create(); + const exposed = trpc.procedure.meta({ mcp: { enabled: true } }); + + const call = async ( + handler: ReturnType, + name: string, + argumentsValue: Record, + ) => { + 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"' }], + ]); + }); +}); diff --git a/apps/nextjs/src/app/api/mcp/_protocol.ts b/apps/nextjs/src/app/api/mcp/_protocol.ts index 222220928..19a4174b6 100644 --- a/apps/nextjs/src/app/api/mcp/_protocol.ts +++ b/apps/nextjs/src/app/api/mcp/_protocol.ts @@ -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( - (current, segment) => - (typeof current === "object" || typeof current === "function") && current !== null - ? (current as Record)[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[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)(input); + const result = await callMcpTool(caller, tool, args); return { content: [ { diff --git a/packages/api/package.json b/packages/api/package.json index ea47a10a7..0ddb8ae4e 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -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:", diff --git a/packages/api/src/mcp-tools.ts b/packages/api/src/mcp-tools.ts index ecfd06a4b..9e5699d25 100644 --- a/packages/api/src/mcp-tools.ts +++ b/packages/api/src/mcp-tools.ts @@ -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,71 +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( router: Router, -): McpTool[] { +): McpToolCatalog { const tools: McpTool[] = []; + const diagnostics: McpToolDiagnostic[] = []; + const names = new Map(); 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, { - io: "input", - 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[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(); + 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)[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)(procedureInput); } diff --git a/packages/api/src/mcp.ts b/packages/api/src/mcp.ts index d4ada2d7c..93db9c2f2 100644 --- a/packages/api/src/mcp.ts +++ b/packages/api/src/mcp.ts @@ -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"; diff --git a/packages/api/src/test/mcp.spec.ts b/packages/api/src/test/mcp.spec.ts index 804a0ccfd..4644617c8 100644 --- a/packages/api/src/test/mcp.spec.ts +++ b/packages/api/src/test/mcp.spec.ts @@ -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().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 = { + "Content-Type": "application/json", + Accept: "application/json, text/event-stream", + }; + const params: Record = {}; + 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 })), + ); + }, +); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b7acf3bb6..a59b4433f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index b2ae2f042..82585c427 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -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 From 7c00c18a4281045884a0eda9f659965f4da569a0 Mon Sep 17 00:00:00 2001 From: Thomas Date: Thu, 10 Sep 2026 19:24:55 +0200 Subject: [PATCH 4/4] docs(mcp): restore custom widget authoring guidance (#6817) --- apps/docs/docs/management/mcp.mdx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/apps/docs/docs/management/mcp.mdx b/apps/docs/docs/management/mcp.mdx index 9cb7cfc40..d4581abf8 100644 --- a/apps/docs/docs/management/mcp.mdx +++ b/apps/docs/docs/management/mcp.mdx @@ -63,10 +63,11 @@ Tools use the permissions of the API key owner or OAuth user. Queries read data; Inspect the **AI / MCP** tab or your client's tool list for available actions. Custom Widget and Workshop authoring tools, prompts, and resources require administrator access. -For Custom Widgets, follow [Connect an agent](./custom-widgets/agent-authoring). -Secret values are configured in Homarr and are never returned through MCP. +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) shares the MCP tool catalog, uses your Homarr session, and asks for approval +The built-in [Assistant](./assistant) uses the same tool catalog with the current Homarr session and asks for approval before mutations by default. ## Verify the connection