fix(mcp): harden tool schemas and dispatch (#6815)

This commit is contained in:
Thomas authored and GitHub committed 2026-09-10 19:19:47 +02:00
1 parent f994d665d2
commit ea3dac752f
12 files changed
+531 -157

No files matched your search

+35 -44
View File
@@ -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: `<id>.<token>`. 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 `<id>.<token>` format.
- **Missing tools**: check the user's permissions. Invalid tool schemas and duplicate names are omitted; check server
logs for `MCP tool omitted from catalog` and report the diagnostic with your Homarr version. Exclude API keys.
- **`Date cannot be represented in JSON Schema`**: update Homarr. Older versions could fail to list all tools because
of an unsupported date schema.
- **`410 Gone` on `/sse` or `/message`**: use `/api/mcp` with a Streamable HTTP client. The old `/api/mcp/mcp` URL remains
an alias.
@@ -7,7 +7,7 @@ import { createUIMessageStream, createUIMessageStreamResponse, jsonSchema, stepC
import { cookies } from "next/headers";
import { z } from "zod/v4";
import { createTRPCContext, mcpRouter } from "@homarr/api/mcp";
import { callMcpTool, createTRPCContext, mcpRouter } from "@homarr/api/mcp";
import {
createAssistantGenerationAccessToken,
getAssistantRequestContextEntitiesAsync,
@@ -547,19 +547,7 @@ export async function POST(request: Request) {
}
if (contextRequestKey !== null) loadedCustomWidgetContextRequests.add(contextRequestKey);
try {
const procedure = mcpTool.pathInRouter.reduce<unknown>(
(current, segment) =>
(typeof current === "object" || typeof current === "function") && current !== null
? (current as Record<string, unknown>)[segment]
: undefined,
caller,
);
if (typeof procedure !== "function") {
throw new Error("Procedure not callable");
}
const result = await (procedure as (value: unknown) => Promise<unknown>)(
executionInput && Object.keys(executionInput as object).length > 0 ? executionInput : undefined,
);
const result = await callMcpTool(caller, mcpTool, executionInput);
customWidgetDiscoveryPhase.observe(mcpTool.name, result);
return toAssistantToolOutput(result, {
maxCharacters: getAssistantToolOutputMaxCharacters(mcpTool.name),
@@ -2,8 +2,11 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import type * as CatalogModule from "../_extract-tools";
const mocks = vi.hoisted(() => ({
authenticate: vi.fn(),
extractCatalog: vi.fn(),
ipAddress: vi.fn(() => "127.0.0.1"),
toolProcedure: vi.fn(),
loggerWarn: vi.fn(),
@@ -14,7 +17,9 @@ const mocks = vi.hoisted(() => ({
}));
vi.mock("next/server", () => ({ userAgent: () => ({ ua: "MCP route test" }) }));
vi.mock("@homarr/api/mcp", () => ({
vi.mock("@homarr/api/mcp", async () => ({
callMcpTool: (await import("../../../../../../../packages/api/src/mcp-tools")).callMcpTool,
extractMcpToolsFromProcedures: mocks.extractCatalog,
createTRPCContext: vi.fn(() => ({})),
mcpRouter: { createCaller: vi.fn(() => ({ board: { getAllBoards: mocks.toolProcedure } })) },
}));
@@ -54,6 +59,7 @@ vi.mock("../_extract-tools", () => ({
description: "List boards",
type: "query",
pathInRouter: ["board", "getAllBoards"],
inputMode: "none",
inputSchema: { type: "object", properties: {} },
},
{
@@ -61,6 +67,7 @@ vi.mock("../_extract-tools", () => ({
description: "List Custom Widgets",
type: "query",
pathInRouter: ["customWidget", "list"],
inputMode: "none",
inputSchema: { type: "object", properties: {} },
},
{
@@ -68,6 +75,7 @@ vi.mock("../_extract-tools", () => ({
description: "Search Workshop",
type: "query",
pathInRouter: ["customWidget", "workshopSearch"],
inputMode: "none",
inputSchema: { type: "object", properties: {} },
},
],
@@ -305,3 +313,21 @@ describe("MCP protocol errors", () => {
expect(metadata).not.toHaveProperty("message");
});
});
test("logs catalog omissions only when the shared catalog is first built", async () => {
const diagnostic = { name: "broken", path: "broken", reason: "invalid_schema" };
const tools = [{ name: "valid" }];
mocks.extractCatalog.mockReturnValue({ tools, diagnostics: [diagnostic] });
const { extractMcpTools } = await vi.importActual<typeof CatalogModule>("../_extract-tools");
expect(extractMcpTools()).toBe(tools);
expect(extractMcpTools()).toBe(tools);
expect(mocks.extractCatalog).toHaveBeenCalledTimes(1);
expect(mocks.loggerWarn).toHaveBeenCalledExactlyOnceWith("MCP tool omitted from catalog", diagnostic);
});
test("rejects unauthenticated tool calls before execution", async () => {
mocks.authenticate.mockResolvedValue(null);
const { response } = await callMcp("tools/call", { name: "board_getAllBoards", arguments: {} });
expect(response.status).toBe(401);
expect(mocks.toolProcedure).not.toHaveBeenCalled();
});
@@ -1,11 +1,12 @@
import { extractMcpToolsFromProcedures, mcpRouter } from "@homarr/api/mcp";
import { createLogger } from "@homarr/core/infrastructure/logs";
const logger = createLogger({ module: "mcp-catalog" });
let cache: ReturnType<typeof extractMcpToolsFromProcedures> | null = null;
export function extractMcpTools() {
if (cache) return cache;
if (cache) return cache.tools;
cache = extractMcpToolsFromProcedures(mcpRouter);
return cache;
for (const diagnostic of cache.diagnostics) logger.warn("MCP tool omitted from catalog", diagnostic);
return cache.tools;
}
+201 -1
View File
@@ -1,12 +1,18 @@
// @vitest-environment node
import { initTRPC, TRPCError } from "@trpc/server";
import { describe, expect, test, vi } from "vitest";
import { z } from "zod/v4";
import type { McpTool } from "@homarr/api/mcp";
import { appManageSchema } from "@homarr/validation/app";
import { extractMcpToolsFromProcedures } from "@homarr/api/mcp";
import type { McpMeta } from "../../../../../../packages/api/src/mcp-tools";
import { createMcpProtocolHandler } from "./_protocol";
vi.mock("@homarr/api/mcp", async () => import("../../../../../../packages/api/src/mcp-tools"));
const modernMetadata = {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientInfo": { name: "homarr-test", version: "1.0.0" },
@@ -52,7 +58,7 @@ const echoTool: McpTool = {
description: "Echo a message",
type: "query",
pathInRouter: ["echo"],
inputValidator: echoValidator,
inputMode: "object",
inputSchema: z.toJSONSchema(echoValidator),
};
@@ -163,3 +169,197 @@ describe("MCP v2 protocol", () => {
expect((await parseResponse(listResponse)).result.tools).toEqual([expect.objectContaining({ name: "echo" })]);
});
});
describe.each([false, true])("MCP argument dispatch (modern=%s)", (modern) => {
const trpc = initTRPC.context<{ user: string }>().meta<McpMeta>().create();
const exposed = trpc.procedure.meta({ mcp: { enabled: true } });
const call = async (
handler: ReturnType<typeof createMcpProtocolHandler>,
name: string,
argumentsValue: Record<string, unknown>,
) => {
let request = createRequest("tools/call", { name, arguments: argumentsValue }, name);
if (!modern) {
request = new Request("http://homarr.test/api/mcp", {
method: "POST",
headers: { "Content-Type": "application/json", Accept: "application/json, text/event-stream" },
body: JSON.stringify({
jsonrpc: "2.0",
id: 1,
method: "tools/call",
params: { name, arguments: argumentsValue },
}),
});
}
const response = await handler.fetch(request);
expect(response.headers.has("Mcp-Session-Id")).toBe(false);
return (await parseResponse(response)).result;
};
test("transforms once and does not coerce, insert defaults, or remove properties before tRPC", async () => {
const transform = vi.fn((value: string) => `${value}!`);
const execution = vi.fn((input: unknown) => input);
const router = trpc.router({
update: exposed
.input(
z.looseObject({
name: z.string().transform(transform),
limit: z.number().default(7),
}),
)
.mutation(({ input }) => execution(input)),
});
const catalog = extractMcpToolsFromProcedures(router);
expect(catalog.diagnostics).toEqual([]);
const realCaller = router.createCaller({ user: "one" });
const dispatch = vi.fn(realCaller.update);
const handler = createMcpProtocolHandler({
caller: { update: dispatch },
tools: catalog.tools,
version: "test",
instructions: "test",
formatToolError: () => "Invalid input",
});
const argumentsValue = { name: "hello", extra: "preserved" };
const result = await call(handler, "update", argumentsValue);
expect(result.isError).not.toBe(true);
expect(dispatch).toHaveBeenCalledWith(argumentsValue);
expect(transform).toHaveBeenCalledTimes(1);
expect(execution).toHaveBeenCalledWith({ name: "hello!", limit: 7, extra: "preserved" });
await call(handler, "update", { name: "hello", limit: "7" });
expect(dispatch).toHaveBeenCalledTimes(1);
expect(execution).toHaveBeenCalledTimes(1);
});
test("retains overlapping chained constraints, refinements, and empty object defaults", async () => {
const execution = vi.fn((input: unknown) => input);
const router = trpc.router({
bounded: exposed
.input(z.object({ count: z.number().min(2) }))
.input(z.object({ count: z.number().max(5) }))
.mutation(({ input }) => execution(input)),
refined: exposed
.input(z.object({ value: z.string().refine((value) => value !== "blocked") }))
.mutation(({ input }) => execution(input)),
defaults: exposed.input(z.object({ count: z.number().default(3) })).query(({ input }) => input),
none: exposed.query(({ input }) => ({ absent: input === undefined })),
});
const catalog = extractMcpToolsFromProcedures(router);
expect(catalog.diagnostics).toEqual([]);
const handler = createMcpProtocolHandler({
caller: router.createCaller({ user: "one" }),
tools: catalog.tools,
version: "test",
instructions: "test",
formatToolError: () => "Invalid input",
});
for (const count of [1, 6]) expect((await call(handler, "bounded", { count })).isError).toBe(true);
expect((await call(handler, "refined", { value: "blocked" })).isError).toBe(true);
expect(execution).not.toHaveBeenCalled();
expect((await call(handler, "bounded", { count: 3 })).isError).not.toBe(true);
expect(execution).toHaveBeenCalledWith({ count: 3 });
expect((await call(handler, "defaults", {})).content).toEqual([{ type: "text", text: '{"count":3}' }]);
expect((await call(handler, "none", {})).content).toEqual([{ type: "text", text: '{"absent":true}' }]);
});
test("accepts app inputs that tRPC normalizes before checking constraints", async () => {
const mutation = vi.fn((input: unknown) => input);
const router = trpc.router({ create: exposed.input(appManageSchema).mutation(({ input }) => mutation(input)) });
const { tools, diagnostics } = extractMcpToolsFromProcedures(router);
expect(diagnostics).toEqual([]);
const handler = createMcpProtocolHandler({
caller: router.createCaller({ user: "admin" }),
tools,
version: "test",
instructions: "test",
formatToolError: () => "Invalid input",
});
const argumentsValue = {
name: `${" ".repeat(64)}App `,
description: " description ",
iconUrl: " icon ",
href: null,
pingUrl: " https://example.com ",
};
expect((await call(handler, "create", argumentsValue)).isError).not.toBe(true);
expect(mutation).toHaveBeenCalledExactlyOnceWith({
name: "App",
description: "description",
iconUrl: "icon",
href: null,
pingUrl: "https://example.com",
});
expect((await call(handler, "create", { ...argumentsValue, pingUrl: " javascript:alert(1) " })).isError).toBe(true);
expect(mutation).toHaveBeenCalledTimes(1);
});
test("accepts Zod ISO timestamps without seconds while rejecting invalid dates", async () => {
const mutation = vi.fn((input: unknown) => input);
const router = trpc.router({
invite: exposed
.input(z.object({ expirationDate: z.iso.datetime({ offset: true }) }))
.mutation(({ input }) => mutation(input)),
});
const { tools, diagnostics } = extractMcpToolsFromProcedures(router);
expect(diagnostics).toEqual([]);
const handler = createMcpProtocolHandler({
caller: router.createCaller({ user: "admin" }),
tools,
version: "test",
instructions: "test",
formatToolError: () => "Invalid input",
});
for (const expirationDate of ["2026-12-01T18:00Z", "2026-12-01T19:00+01:00"]) {
expect((await call(handler, "invite", { expirationDate })).isError).not.toBe(true);
expect(mutation).toHaveBeenLastCalledWith({ expirationDate });
}
for (const expirationDate of ["2026-02-30T18:00Z", "not-a-date"]) {
expect((await call(handler, "invite", { expirationDate })).isError).toBe(true);
}
expect(mutation).toHaveBeenCalledTimes(2);
});
test("enforces tRPC permissions before executing mutations", async () => {
const mutation = vi.fn(() => "changed");
const router = trpc.router({
restricted: exposed
.use(({ ctx, next }) => {
if (ctx.user !== "admin") throw new TRPCError({ code: "FORBIDDEN" });
return next();
})
.input(z.object({}))
.mutation(mutation),
});
const { tools, diagnostics } = extractMcpToolsFromProcedures(router);
expect(diagnostics).toEqual([]);
const handler = createMcpProtocolHandler({
caller: router.createCaller({ user: "guest" }),
tools,
version: "test",
instructions: "test",
formatToolError: () => "Forbidden",
});
expect((await call(handler, "restricted", {})).isError).toBe(true);
expect(mutation).not.toHaveBeenCalled();
});
test("isolates authenticated callers when reusing catalog schemas", async () => {
const router = trpc.router({ who: exposed.query(({ ctx }) => ctx.user) });
const { tools } = extractMcpToolsFromProcedures(router);
const handlers = ["alice", "bob"].map((user) =>
createMcpProtocolHandler({
caller: router.createCaller({ user }),
tools,
version: "test",
instructions: "test",
formatToolError: () => "Failed",
}),
);
const results = await Promise.all(handlers.map((handler) => call(handler, "who", {})));
expect(results.map((result) => result.content)).toEqual([
[{ type: "text", text: '"alice"' }],
[{ type: "text", text: '"bob"' }],
]);
});
});
+4 -21
View File
@@ -1,5 +1,6 @@
import { createMcpHandler, McpServer } from "@modelcontextprotocol/server";
import { createMcpHandler, fromJsonSchema, McpServer } from "@modelcontextprotocol/server";
import { callMcpTool } from "@homarr/api/mcp";
import type { McpTool } from "@homarr/api/mcp";
import { toAssistantToolOutput } from "../assistant/chat/assistant-tool-output";
@@ -15,15 +16,6 @@ interface CreateMcpProtocolHandlerOptions {
onProtocolError?: (error: Error) => void;
}
const resolveProcedure = (caller: unknown, path: string[]) =>
path.reduce<unknown>(
(current, segment) =>
(typeof current === "object" || typeof current === "function") && current !== null
? (current as Record<string, unknown>)[segment]
: undefined,
caller,
);
export const createMcpProtocolHandler = ({
caller,
tools,
@@ -52,20 +44,11 @@ export const createMcpProtocolHandler = ({
tool.name,
{
description: tool.description,
inputSchema: tool.inputValidator,
inputSchema: fromJsonSchema(tool.inputSchema as Parameters<typeof fromJsonSchema>[0]),
},
async (args) => {
try {
const procedure = resolveProcedure(caller, tool.pathInRouter);
if (typeof procedure !== "function") {
return {
content: [{ type: "text" as const, text: `Tool "${tool.name}" is not callable` }],
isError: true,
};
}
const input = Object.keys(args).length > 0 ? args : undefined;
const result = await (procedure as (value: unknown) => Promise<unknown>)(input);
const result = await callMcpTool(caller, tool, args);
return {
content: [
{
+2
View File
@@ -51,11 +51,13 @@
"@homarr/validation": "workspace:^0.1.0",
"@homarr/workshop": "workspace:^0.1.0",
"@kubernetes/client-node": "catalog:",
"@modelcontextprotocol/server": "catalog:",
"@tanstack/react-query": "catalog:",
"@trpc/client": "catalog:",
"@trpc/react-query": "catalog:",
"@trpc/server": "catalog:",
"@trpc/tanstack-react-query": "catalog:",
"ajv": "catalog:",
"lodash.clonedeep": "catalog:",
"next": "catalog:",
"react": "catalog:",
+116 -63
View File
@@ -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<TRoot extends AnyRootTypes, TRecord extends RouterRecord>(
router: Router<TRoot, TRecord>,
): McpTool[] {
): McpToolCatalog {
const tools: McpTool[] = [];
const diagnostics: McpToolDiagnostic[] = [];
const names = new Map<string, string[]>();
for (const [path, procedure] of Object.entries(router["_def"].procedures)) {
if (!isProcedure(procedure)) continue;
const definition = procedure["_def"];
if (definition.type === "subscription") continue;
const metadata = getMcpMetadata(definition.meta);
if (!metadata?.enabled) continue;
const inputs = definition.inputs.filter((input): input is z.ZodType => input instanceof z.ZodType);
const procedureInput = getProcedureInput(inputs);
if (
procedureInput === null ||
(!(procedureInput instanceof z.ZodObject) && !procedureInput.safeParse(undefined).success)
) {
console.warn(`[MCP] Procedure ${path} does not use an object input schema; using an empty schema`);
const name = metadata.name ?? path.replaceAll(".", "_");
const paths = names.get(name) ?? [];
paths.push(path);
names.set(name, paths);
if (!/^[a-zA-Z0-9_.-]{1,128}$/.test(name)) {
diagnostics.push({ name, path, reason: "invalid_name" });
continue;
}
const inputValidator = procedureInput instanceof z.ZodObject ? procedureInput : emptyInput;
tools.push({
name: metadata.name ?? path.replaceAll(".", "_"),
description: metadata.description ?? "",
type: definition.type,
pathInRouter: path.split("."),
inputValidator,
inputSchema: normalizeJsonSchema(
z.toJSONSchema(inputValidator, {
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<typeof schemaValidator.getValidator>[0]);
tools.push({
name,
description: metadata.description ?? "",
type: definition.type,
pathInRouter: path.split("."),
inputMode,
inputSchema,
});
} catch {
// Conversion errors can contain schema metadata. Only expose a stable, safe reason.
diagnostics.push({ name, path, reason: "invalid_schema" });
}
}
return tools.toSorted((left, right) => left.name.localeCompare(right.name));
const duplicates = new Set<string>();
for (const [name, paths] of names) {
if (paths.length < 2) continue;
duplicates.add(name);
for (const path of paths) diagnostics.push({ name, path, reason: "duplicate_name" });
}
return {
tools: tools
.filter((tool) => !duplicates.has(tool.name))
.toSorted((left, right) => left.name.localeCompare(right.name)),
diagnostics: diagnostics.toSorted(
(left, right) => left.path.localeCompare(right.path) || left.reason.localeCompare(right.reason),
),
};
}
/** Dispatch raw JSON through the caller so tRPC owns parsing, transforms, and authorization. */
export async function callMcpTool(caller: unknown, tool: McpTool, input: unknown) {
let procedure = caller;
for (const segment of tool.pathInRouter) {
if ((typeof procedure !== "object" && typeof procedure !== "function") || procedure === null) {
throw new Error("Tool is not callable");
}
procedure = (procedure as Record<string, unknown>)[segment];
}
if (typeof procedure !== "function") throw new Error("Tool is not callable");
let procedureInput = input;
if (tool.inputMode === "none") procedureInput = undefined;
else if (input === undefined) procedureInput = {};
return await (procedure as (value: unknown) => Promise<unknown>)(procedureInput);
}
+1 -1
View File
@@ -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";
+128 -8
View File
@@ -1,9 +1,12 @@
import { initTRPC } from "@trpc/server";
import type { TRPCError } from "@trpc/server";
import { describe, expect, test, vi } from "vitest";
import { z } from "zod/v4";
import { mcpRouter } from "../mcp";
import { extractMcpToolsFromProcedures } from "../mcp-tools";
import type { McpMeta } from "../mcp-tools";
import { createMcpProtocolHandler } from "../../../../apps/nextjs/src/app/api/mcp/_protocol";
import { callMcpTool, extractMcpToolsFromProcedures } from "../mcp-tools";
vi.mock("@homarr/auth", () => ({}));
@@ -157,7 +160,7 @@ function actualToolInventory() {
{ _def?: { type?: "query" | "mutation" } }
>;
return extractMcpToolsFromProcedures(mcpRouter)
.map((tool) => {
.tools.map((tool) => {
const procedure = procedures[tool.pathInRouter.join(".")];
const type = procedure?.["_def"]?.type;
if (type !== "query" && type !== "mutation") throw new Error(`Unable to classify MCP tool '${tool.name}'`);
@@ -175,7 +178,7 @@ describe("production MCP router", () => {
});
test("keeps every secret-bearing tool behind a mutation procedure", () => {
const tools = extractMcpToolsFromProcedures(mcpRouter);
const tools = extractMcpToolsFromProcedures(mcpRouter).tools;
const procedures = mcpRouter["_def"].procedures as unknown as Record<
string,
{ _def?: { type?: "query" | "mutation" } }
@@ -189,7 +192,7 @@ describe("production MCP router", () => {
});
test("gives every production tool a description", () => {
const tools = extractMcpToolsFromProcedures(mcpRouter);
const tools = extractMcpToolsFromProcedures(mcpRouter).tools;
for (const tool of tools) expect(tool.description, `Tool ${tool.name} should have a description`).toBeTruthy();
expect(tools.find((tool) => tool.name === "customWidget_getAuthoringPrompt")?.description).toBe(
"Get the current Custom Widget authoring instructions.",
@@ -197,16 +200,17 @@ describe("production MCP router", () => {
});
});
test("MCP tools are deterministically ordered and retain executable schemas", () => {
const tools = extractMcpToolsFromProcedures(mcpRouter);
test("MCP tools are deterministically ordered and have valid production schemas", () => {
expect(extractMcpToolsFromProcedures(mcpRouter).diagnostics).toEqual([]);
const tools = extractMcpToolsFromProcedures(mcpRouter).tools;
const toolNames = tools.map((tool) => tool.name);
expect(toolNames).toEqual(toolNames.toSorted((left, right) => left.localeCompare(right)));
expect(tools.every((tool) => tool.inputValidator instanceof z.ZodObject)).toBe(true);
expect(tools.every((tool) => tool.inputSchema.type === "object")).toBe(true);
});
test("publishes both Custom Widget template input formats", () => {
const tool = extractMcpToolsFromProcedures(mcpRouter).find(
const tool = extractMcpToolsFromProcedures(mcpRouter).tools.find(
(candidate) => candidate.name === "customWidget_validateTemplate",
);
@@ -355,3 +359,119 @@ describe("custom widget authoring procedure access", () => {
);
});
});
describe("MCP catalog diagnostics", () => {
const trpc = initTRPC.meta<McpMeta>().create();
const exposed = trpc.procedure.meta({ mcp: { enabled: true } });
test("omits unsupported parsers and malformed schemas without losing valid tools", () => {
const router = trpc.router({
good: exposed.input(z.object({ name: z.string() })).query(() => "ok"),
date: exposed.input(z.object({ date: z.date() })).query(() => "unused"),
scalar: exposed.input(z.string()).query(() => "unused"),
parser: exposed.input({ parse: (value: unknown) => value }).query(() => "unused"),
malformed: exposed
.input(z.object({ value: z.string().meta({ type: "invalid-json-type" }) }))
.query(() => "unused"),
});
const catalog = extractMcpToolsFromProcedures(router);
expect(catalog.tools.map((tool) => tool.name)).toEqual(["good"]);
expect(catalog.diagnostics).toEqual([
{ name: "date", path: "date", reason: "invalid_schema" },
{ name: "malformed", path: "malformed", reason: "invalid_schema" },
{ name: "parser", path: "parser", reason: "unsupported_input" },
{ name: "scalar", path: "scalar", reason: "unsupported_input" },
]);
});
test("omits all duplicate names even when one procedure has an invalid schema", () => {
const shared = trpc.procedure.meta({ mcp: { enabled: true, name: "shared" } });
const catalog = extractMcpToolsFromProcedures(
trpc.router({
first: shared.query(() => "first"),
second: shared.input(z.object({ date: z.date() })).query(() => "second"),
good: exposed.query(() => "good"),
invalidName: trpc.procedure.meta({ mcp: { enabled: true, name: "bad name" } }).query(() => "unused"),
}),
);
expect(catalog.tools.map((tool) => tool.name)).toEqual(["good"]);
expect(catalog.diagnostics.filter((diagnostic) => diagnostic.reason === "duplicate_name")).toEqual([
{ name: "shared", path: "first", reason: "duplicate_name" },
{ name: "shared", path: "second", reason: "duplicate_name" },
]);
expect(catalog.diagnostics).toContainEqual({ name: "bad name", path: "invalidName", reason: "invalid_name" });
});
test("distinguishes empty object input from no input for shared Assistant dispatch", async () => {
const router = trpc.router({
none: exposed.query(({ input }) => input),
explicitNone: exposed.input(z.undefined()).query(({ input }) => input),
object: exposed.input(z.object({ value: z.string().default("default") })).query(({ input }) => input),
optional: exposed
.input(z.object({ value: z.string().default("default") }).optional())
.query(({ input }) => input),
});
const catalog = extractMcpToolsFromProcedures(router);
expect(catalog.diagnostics).toEqual([]);
for (const tool of catalog.tools) {
const result = await callMcpTool(router.createCaller({}), tool, {});
if (tool.inputMode === "none") expect(result).toBeUndefined();
else expect(result).toEqual({ value: "default" });
}
});
});
test.each([false, true])(
"production catalog registers through the real protocol handler (modern=%s)",
async (modern) => {
const { tools, diagnostics } = extractMcpToolsFromProcedures(mcpRouter);
expect(diagnostics).toEqual([]);
const handler = createMcpProtocolHandler({
caller: {},
tools,
version: "test",
instructions: "test",
formatToolError: () => "Tool failed",
});
const headers: Record<string, string> = {
"Content-Type": "application/json",
Accept: "application/json, text/event-stream",
};
const params: Record<string, unknown> = {};
if (modern) {
headers["Mcp-Method"] = "tools/list";
headers["Mcp-Name"] = "homarr";
params["_meta"] = {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientInfo": { name: "catalog-test", version: "1" },
"io.modelcontextprotocol/clientCapabilities": {},
};
}
const response = await handler.fetch(
new Request("http://homarr.test/api/mcp", {
method: "POST",
headers,
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list", params }),
}),
);
expect(response.status).toBe(200);
expect(response.headers.has("Mcp-Session-Id")).toBe(false);
const body = await response.text();
let payload = body;
if (response.headers.get("Content-Type")?.includes("text/event-stream")) {
payload =
body
.split("\n")
.find((line) => line.startsWith("data: "))
?.slice(6) ?? "";
}
const result = JSON.parse(payload) as {
error?: unknown;
result: { tools: { name: string; inputSchema: unknown }[] };
};
expect(result.error).toBeUndefined();
expect(result.result.tools.map((tool) => ({ name: tool.name, inputSchema: tool.inputSchema }))).toEqual(
tools.map((tool) => ({ name: tool.name, inputSchema: tool.inputSchema })),
);
},
);
+9
View File
@@ -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
+1
View File
@@ -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