From 8d5166ce0a44fd00289c89e435d7f47c3f935671 Mon Sep 17 00:00:00 2001 From: ajnart Date: Thu, 10 Sep 2026 19:03:31 +0200 Subject: [PATCH] fix(mcp): harden tool schemas and dispatch --- apps/docs/docs/management/mcp.mdx | 18 ++ .../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 | 144 +++++++++++++++- apps/nextjs/src/app/api/mcp/_protocol.ts | 25 +-- packages/api/package.json | 2 + packages/api/src/mcp-tools.ts | 162 +++++++++++------- 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, 439 insertions(+), 113 deletions(-) diff --git a/apps/docs/docs/management/mcp.mdx b/apps/docs/docs/management/mcp.mdx index 3a37ce9da..5e8043635 100644 --- a/apps/docs/docs/management/mcp.mdx +++ b/apps/docs/docs/management/mcp.mdx @@ -16,6 +16,10 @@ current Homarr API to tools for compatible AI clients. 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. +Each HTTP request authenticates independently and uses a fresh MCP server instance. Neither protocol path requires an +MCP session ID or server affinity. Homarr still checks the authenticated user’s permissions for every tool call; +stateless MCP does not remove authentication or the storage used by OAuth authorization flows. + ## Configure a client Create an API key under **Management → Tools → API → Authentication**, then add the endpoint and header to the client's @@ -93,6 +97,20 @@ The generated catalog includes available board, app, integration, Docker, Kubern 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. +If a tool cannot be represented by a valid JSON input schema, Homarr omits it while keeping the rest of the catalog +available. Duplicate tool names are also omitted rather than replacing one another. The Assistant uses the same +filtered catalog. Check server logs for `MCP tool omitted from catalog`, which includes the tool name, procedure path, +and one of these reasons: + +- `unsupported_input`: the procedure does not declare supported object inputs or a no-input schema. +- `invalid_schema`: input schema conversion or JSON Schema validation failed. +- `duplicate_name`: multiple enabled procedures resolve to the same tool name. +- `invalid_name`: the tool name does not meet MCP naming requirements. + +Diagnostics are logged once when each server process builds its catalog. They describe server-side tool definitions; +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. + 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/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..078460625 100644 --- a/apps/nextjs/src/app/api/mcp/_protocol.spec.ts +++ b/apps/nextjs/src/app/api/mcp/_protocol.spec.ts @@ -1,12 +1,17 @@ // @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 { 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 +57,7 @@ const echoTool: McpTool = { description: "Echo a message", type: "query", pathInRouter: ["echo"], - inputValidator: echoValidator, + inputMode: "object", inputSchema: z.toJSONSchema(echoValidator), }; @@ -163,3 +168,140 @@ 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("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..74024af8b 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,93 @@ 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" }); + // 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