diff --git a/apps/docs/docs/management/mcp.mdx b/apps/docs/docs/management/mcp.mdx index 5e8043635..626ca643a 100644 --- a/apps/docs/docs/management/mcp.mdx +++ b/apps/docs/docs/management/mcp.mdx @@ -111,6 +111,10 @@ Diagnostics are logged once when each server process builds its catalog. They de changing credentials will not repair them. Report the diagnostic and Homarr version without including API keys. Catalog validation in CI rejects these diagnostics before release. +Tool calls preserve raw JSON arguments until tRPC validates and normalizes them. String constraints that depend on +trimming or case normalization are enforced by tRPC after normalization. ISO timestamps use the declared input +pattern, including minute precision when supported. + 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`. diff --git a/apps/nextjs/src/app/api/mcp/_protocol.spec.ts b/apps/nextjs/src/app/api/mcp/_protocol.spec.ts index 078460625..a4b81acbb 100644 --- a/apps/nextjs/src/app/api/mcp/_protocol.spec.ts +++ b/apps/nextjs/src/app/api/mcp/_protocol.spec.ts @@ -5,6 +5,7 @@ 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"; @@ -262,6 +263,63 @@ describe.each([false, true])("MCP argument dispatch (modern=%s)", (modern) => { 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({ diff --git a/packages/api/src/mcp-tools.ts b/packages/api/src/mcp-tools.ts index 74024af8b..9e5699d25 100644 --- a/packages/api/src/mcp-tools.ts +++ b/packages/api/src/mcp-tools.ts @@ -103,7 +103,24 @@ export function extractMcpToolsFromProcedures { + 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";