From ea3dac752f4a656361219e08271b34111ad98905 Mon Sep 17 00:00:00 2001 From: Thomas Date: Thu, 10 Sep 2026 19:19:47 +0200 Subject: [PATCH] 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