feat: migrate MCP server to v2

This commit is contained in:
ajnart committed 2026-08-09 08:25:17 +02:00
1 parent 8fea2a2851
commit fa9d080eed
22 files changed
+774 -1238

No files matched your search

+83
View File
@@ -0,0 +1,83 @@
import type { AnyRootTypes, Router, RouterRecord } from "@trpc/server/unstable-core-do-not-import";
import { z } from "zod/v4";
export interface McpMeta {
mcp?: {
enabled: boolean;
name?: string;
description?: string;
};
}
export interface McpTool {
name: string;
description: string;
pathInRouter: string[];
inputSchema: z.core.JSONSchema.JSONSchema;
inputValidator: z.ZodObject;
}
const emptyInput = z.object({});
const mergeInputs = (inputs: z.ZodObject[]) => inputs.reduce((merged, input) => merged.extend(input.shape), emptyInput);
const normalizeJsonSchema = (schema: z.core.JSONSchema.JSONSchema): z.core.JSONSchema.JSONSchema => {
const properties = (schema.properties ?? {}) as Record<string, Record<string, unknown>>;
const required = ((schema.required as string[] | undefined) ?? []).filter(
(key) => !("default" in (properties[key] ?? {})),
);
return {
...schema,
type: "object",
properties,
required,
};
};
export function extractMcpToolsFromProcedures<TRoot extends AnyRootTypes, TRecord extends RouterRecord>(
router: Router<TRoot, TRecord>,
): McpTool[] {
const tools: McpTool[] = [];
for (const [path, procedure] of Object.entries(router["_def"].procedures)) {
const definition = procedure["_def"] as unknown as {
inputs?: z.ZodType[];
meta?: McpMeta;
};
const metadata = definition.meta?.mcp;
if (!metadata?.enabled) continue;
const inputs = (definition.inputs ?? []).filter((input): input is z.ZodType => input !== undefined);
const procedureInput =
inputs.length === 0
? emptyInput
: inputs.length === 1
? (inputs[0] ?? emptyInput)
: inputs.every((input): input is z.ZodObject => input instanceof z.ZodObject)
? mergeInputs(inputs)
: null;
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 inputValidator = procedureInput instanceof z.ZodObject ? procedureInput : emptyInput;
tools.push({
name: metadata.name ?? path.replaceAll(".", "_"),
description: metadata.description ?? "",
pathInRouter: path.split("."),
inputValidator,
inputSchema: normalizeJsonSchema(
z.toJSONSchema(inputValidator, {
unrepresentable: "any",
}),
),
});
}
return tools.toSorted((left, right) => left.name.localeCompare(right.name));
}
+2
View File
@@ -1,4 +1,6 @@
export { createTRPCContext } from "./trpc";
export { extractMcpToolsFromProcedures } from "./mcp-tools";
export type { McpTool } from "./mcp-tools";
import { createTRPCRouter } from "./trpc";
import { appRouter as appRouterForApps } from "./router/app";
+13 -4
View File
@@ -1,8 +1,9 @@
import type { TRPCError } from "@trpc/server";
import { describe, expect, test, vi } from "vitest";
import { extractToolsFromProcedures } from "trpc-to-mcp";
import { z } from "zod/v4";
import { mcpRouter } from "../mcp";
import { extractMcpToolsFromProcedures } from "../mcp-tools";
vi.mock("@homarr/auth", () => ({}));
@@ -125,7 +126,7 @@ function actualToolInventory() {
string,
{ _def?: { type?: "query" | "mutation" } }
>;
return extractToolsFromProcedures(mcpRouter)
return extractMcpToolsFromProcedures(mcpRouter)
.map((tool) => {
const procedure = procedures[tool.pathInRouter.join(".")];
const type = procedure?.["_def"]?.type;
@@ -144,7 +145,7 @@ describe("production MCP router", () => {
});
test("keeps every secret-bearing tool behind a mutation procedure", () => {
const tools = extractToolsFromProcedures(mcpRouter);
const tools = extractMcpToolsFromProcedures(mcpRouter);
const procedures = mcpRouter["_def"].procedures as unknown as Record<
string,
{ _def?: { type?: "query" | "mutation" } }
@@ -158,7 +159,7 @@ describe("production MCP router", () => {
});
test("gives every production tool a description", () => {
const tools = extractToolsFromProcedures(mcpRouter);
const tools = extractMcpToolsFromProcedures(mcpRouter);
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.",
@@ -166,6 +167,14 @@ describe("production MCP router", () => {
});
});
test("MCP tools are deterministically ordered and retain executable schemas", () => {
const tools = extractMcpToolsFromProcedures(mcpRouter);
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);
});
describe("custom widget authoring procedure access", () => {
const unauthenticatedCaller = mcpRouter.createCaller({
db: null as never,
+1 -1
View File
@@ -8,7 +8,6 @@
*/
import { initTRPC, TRPCError } from "@trpc/server";
import superjson from "superjson";
import type { McpMeta } from "trpc-to-mcp";
import type { OpenApiMeta } from "trpc-to-openapi";
import { ZodError } from "zod/v4";
@@ -22,6 +21,7 @@ import type { GroupPermissionKey, OnboardingStep } from "@homarr/definitions";
import { env } from "./env";
import { getOnboardingOrFallbackAsync } from "./router/onboard/onboard-queries";
import type { McpMeta } from "./mcp-tools";
const logger = createLogger({ module: "trpc" });