refactor: simplify widget and integration architecture

This commit is contained in:
ajnart committed 2026-08-24 00:57:36 +02:00
1 parent c988c35b29
commit 8f9efd0074
179 files changed
+1509 -4187

No files matched your search

@@ -6,7 +6,7 @@ import type { ToolSet, UIMessage } from "ai";
import { createUIMessageStream, createUIMessageStreamResponse, jsonSchema, stepCountIs, streamText, tool } from "ai";
import { z } from "zod/v4";
import { createTRPCContext } from "@homarr/api/mcp";
import { createTRPCContext, mcpRouter } from "@homarr/api/mcp";
import {
createAssistantGenerationAccessToken,
getAssistantContextEntitiesAsync,
@@ -16,7 +16,6 @@ import { auth } from "@homarr/auth/next";
import { env } from "@homarr/common/env";
import { decryptSecret } from "@homarr/common/server";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
import { and, eq } from "@homarr/db";
import { db } from "@homarr/db";
import { assistantConfigurations, assistantThreads } from "@homarr/db/schema";
@@ -36,7 +35,7 @@ import type {
AssistantWebSearchSource,
} from "~/components/assistant/assistant-message-metadata";
import { getMcpRuntimeAsync } from "../../mcp/_extract-tools";
import { extractMcpTools } from "../../mcp/_extract-tools";
import { buildAssistantRequestContext, sanitizeAttachmentFilename } from "./assistant-chat-input";
import { getAssistantModelLookupStatus } from "./assistant-model-lookup";
import { convertAssistantMessagesToModelMessages } from "./assistant-message-conversion";
@@ -493,24 +492,14 @@ export async function POST(request: Request) {
}
const canAuthorCustomWidgets = session.user.permissions.includes("admin");
const customWidgetAuthoringContext = getCustomWidgetAuthoringContext(incomingMessages, canAuthorCustomWidgets);
let mcpRuntime;
try {
mcpRuntime = await getMcpRuntimeAsync();
} catch (error) {
logger.error(new ErrorWithMetadata("Assistant tools are unavailable", { requestId }, { cause: error }));
return Response.json(
{ error: "Homarr tools could not be loaded. Check the Homarr logs and try again." },
{ status: 503 },
);
}
const caller = mcpRuntime.router.createCaller(context);
const caller = mcpRouter.createCaller(context);
const omittedCustomWidgetTools = new Set<string>(customWidgetAuthoringContext.omittedToolNames);
const mcpTools = mcpRuntime.tools.filter((mcpTool) => !omittedCustomWidgetTools.has(mcpTool.name));
const mcpTools = extractMcpTools().filter((mcpTool) => !omittedCustomWidgetTools.has(mcpTool.name));
const customWidgetComponentDocumentBudget = createCustomWidgetComponentDocumentBudget();
const homarrTools = Object.fromEntries(
mcpTools.map((mcpTool) => {
const requiresApproval = mcpRuntime.procedureTypes.get(mcpTool.pathInRouter.join(".")) === "mutation";
const requiresApproval = mcpTool.type === "mutation";
return [
mcpTool.name,
tool({
@@ -558,11 +547,9 @@ export async function POST(request: Request) {
}),
) satisfies ToolSet;
const toolApproval = Object.fromEntries(
mcpTools.flatMap((mcpTool) =>
mcpRuntime.procedureTypes.get(mcpTool.pathInRouter.join(".")) === "mutation"
? [[mcpTool.name, "user-approval" as const]]
: [],
),
mcpTools
.filter((mcpTool) => mcpTool.type === "mutation")
.map((mcpTool) => [mcpTool.name, "user-approval" as const]),
);
const frontendTools = Object.fromEntries(
@@ -16,6 +16,7 @@ const mocks = vi.hoisted(() => ({
vi.mock("next/server", () => ({ userAgent: () => ({ ua: "MCP route test" }) }));
vi.mock("@homarr/api/mcp", () => ({
createTRPCContext: vi.fn(() => ({})),
mcpRouter: { createCaller: vi.fn(() => ({ board: { getAllBoards: mocks.toolProcedure } })) },
}));
vi.mock("@homarr/auth/api-key", () => ({
API_KEY_HEADER_NAME: "ApiKey",
@@ -41,30 +42,29 @@ vi.mock("@homarr/custom-widgets/core", () => ({
vi.mock("@homarr/db", () => ({ db: {} }));
vi.mock("~/versions/package-reader", () => ({ getPackageVersion: () => "test-version" }));
vi.mock("../_extract-tools", () => ({
getMcpRuntimeAsync: async () => ({
router: { createCaller: vi.fn(() => ({ board: { getAllBoards: mocks.toolProcedure } })) },
procedureTypes: new Map([["board.getAllBoards", "query"]]),
tools: [
{
name: "board_getAllBoards",
description: "List boards",
pathInRouter: ["board", "getAllBoards"],
inputSchema: { type: "object", properties: {} },
},
{
name: "customWidget_list",
description: "List Custom Widgets",
pathInRouter: ["customWidget", "list"],
inputSchema: { type: "object", properties: {} },
},
{
name: "customWidget_workshopSearch",
description: "Search Workshop",
pathInRouter: ["customWidget", "workshopSearch"],
inputSchema: { type: "object", properties: {} },
},
],
}),
extractMcpTools: () => [
{
name: "board_getAllBoards",
description: "List boards",
type: "query",
pathInRouter: ["board", "getAllBoards"],
inputSchema: { type: "object", properties: {} },
},
{
name: "customWidget_list",
description: "List Custom Widgets",
type: "query",
pathInRouter: ["customWidget", "list"],
inputSchema: { type: "object", properties: {} },
},
{
name: "customWidget_workshopSearch",
description: "Search Workshop",
type: "query",
pathInRouter: ["customWidget", "workshopSearch"],
inputSchema: { type: "object", properties: {} },
},
],
}));
import { POST } from "./route";
+7 -51
View File
@@ -1,55 +1,11 @@
import type { McpRouter, McpTool } from "@homarr/api/mcp";
import { extractMcpToolsFromProcedures, getMcpRouterAsync } from "@homarr/api/mcp";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
import { extractMcpToolsFromProcedures, mcpRouter } from "@homarr/api/mcp";
const logger = createLogger({ module: "mcpTools" });
let cache: ReturnType<typeof extractMcpToolsFromProcedures> | null = null;
export type McpProcedureType = "query" | "mutation";
export function extractMcpTools() {
if (cache) return cache;
export interface McpRuntime {
router: McpRouter;
tools: McpTool[];
procedureTypes: ReadonlyMap<string, McpProcedureType>;
}
const createMcpRuntimeAsync = async (): Promise<McpRuntime> => {
const startedAt = Date.now();
const router = await getMcpRouterAsync();
const tools = extractMcpToolsFromProcedures(router);
const procedures = router["_def"].procedures as unknown as Record<string, { ["_def"]?: { type?: McpProcedureType } }>;
const procedureTypes = new Map<string, McpProcedureType>();
for (const [path, procedure] of Object.entries(procedures)) {
const type = procedure["_def"]?.type;
if (type === "query" || type === "mutation") procedureTypes.set(path, type);
}
logger.info("Extracted MCP tools", {
durationMs: Date.now() - startedAt,
toolCount: tools.length,
});
return { router, tools, procedureTypes };
};
let mcpRuntimePromise: Promise<McpRuntime> | null = null;
export async function getMcpRuntimeAsync(): Promise<McpRuntime> {
if (mcpRuntimePromise) return await mcpRuntimePromise;
const loadPromise = createMcpRuntimeAsync();
mcpRuntimePromise = loadPromise;
try {
return await loadPromise;
} catch (error) {
if (mcpRuntimePromise === loadPromise) mcpRuntimePromise = null;
logger.error(new ErrorWithMetadata("Failed to extract MCP tools", {}, { cause: error }));
throw error;
}
}
export async function extractMcpTools(): Promise<McpTool[]> {
const { tools } = await getMcpRuntimeAsync();
return tools;
cache = extractMcpToolsFromProcedures(mcpRouter);
return cache;
}
+8 -18
View File
@@ -5,12 +5,11 @@ import { ResourceTemplate } from "@modelcontextprotocol/server";
import { z } from "zod/v4";
import type { McpTool } from "@homarr/api/mcp";
import { createTRPCContext } from "@homarr/api/mcp";
import { createTRPCContext, mcpRouter } from "@homarr/api/mcp";
import { API_KEY_HEADER_NAME, getSessionFromApiKeyAsync } from "@homarr/auth/api-key";
import { extractBaseUrlFromHeaders } from "@homarr/common";
import { ipAddressFromHeaders } from "@homarr/common/server";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
import { buildCustomWidgetMcpPrompt } from "@homarr/custom-widgets/authoring-prompt";
import {
getCustomWidgetComponent,
@@ -23,7 +22,7 @@ import { db } from "@homarr/db";
import { getPackageVersion } from "~/versions/package-reader";
import { getSafeAssistantToolError } from "../assistant/chat/assistant-tool-error";
import { getMcpRuntimeAsync } from "./_extract-tools";
import { extractMcpTools } from "./_extract-tools";
import { createMcpProtocolHandler } from "./_protocol";
const logger = createLogger({ module: "mcpRoute" });
@@ -81,8 +80,10 @@ function clearAuthFailures(ip: string | null) {
authFailures.delete(ip ?? "unknown");
}
const getVisibleTools = (tools: McpTool[], isAdmin: boolean) =>
isAdmin ? tools : tools.filter((tool) => !tool.name.startsWith("customWidget_"));
const getVisibleTools = (tools: McpTool[], isAdmin: boolean) => {
if (isAdmin) return tools;
return tools.filter((tool) => !tool.name.startsWith("customWidget_"));
};
function registerCustomWidgetAuthoring(server: McpServer) {
server.registerPrompt(
@@ -322,23 +323,12 @@ export const handleMcpRequest = async (req: NextRequest) => {
clearAuthFailures(ipAddress);
logger.info("MCP request authenticated", { userId: session.user.id });
let mcpRuntime;
try {
mcpRuntime = await getMcpRuntimeAsync();
} catch (error) {
logger.error(new ErrorWithMetadata("MCP runtime is unavailable", {}, { cause: error }));
return jsonErrorResponse(503, {
error: "service_unavailable",
hint: "The MCP tool registry could not be loaded. Check the Homarr logs and try again.",
});
}
const ctx = createTRPCContext({ session, headers: req.headers });
const caller = mcpRuntime.router.createCaller(ctx);
const caller = mcpRouter.createCaller(ctx);
const isAdmin = session.user.permissions.includes("admin");
const protocolHandler = createMcpProtocolHandler({
caller,
tools: getVisibleTools(mcpRuntime.tools, isAdmin),
tools: getVisibleTools(extractMcpTools(), isAdmin),
version: getPackageVersion(),
instructions: SERVER_INSTRUCTIONS,
configureServer: isAdmin ? registerCustomWidgetAuthoring : undefined,
@@ -50,6 +50,7 @@ const echoValidator = z.object({ message: z.string() });
const echoTool: McpTool = {
name: "echo",
description: "Echo a message",
type: "query",
pathInRouter: ["echo"],
inputValidator: echoValidator,
inputSchema: z.toJSONSchema(echoValidator),