refactor: streamline widget and integration architecture

This commit is contained in:
ajnart committed 2026-08-23 22:33:29 +02:00
1 parent ec295fe26e
commit 425cb653a1
243 files changed
+5722 -2255

No files matched your search

+17 -21
View File
@@ -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, mcpRouter } from "@homarr/api/mcp";
import { createTRPCContext } from "@homarr/api/mcp";
import {
createAssistantGenerationAccessToken,
getAssistantContextEntitiesAsync,
@@ -16,6 +16,7 @@ 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";
@@ -35,7 +36,7 @@ import type {
AssistantWebSearchSource,
} from "~/components/assistant/assistant-message-metadata";
import { extractMcpTools } from "../../mcp/_extract-tools";
import { getMcpRuntimeAsync } from "../../mcp/_extract-tools";
import { buildAssistantRequestContext, sanitizeAttachmentFilename } from "./assistant-chat-input";
import { getAssistantModelLookupStatus } from "./assistant-model-lookup";
import { convertAssistantMessagesToModelMessages } from "./assistant-message-conversion";
@@ -322,20 +323,6 @@ const sumCompleteStepMetric = (
return (values as number[]).reduce((sum, value) => sum + value, 0);
};
const getProcedureTypeMap = () => {
const procedures = (
mcpRouter as unknown as {
["_def"]: { procedures: Record<string, { ["_def"]?: { type?: string } }> };
}
)["_def"].procedures;
return new Map(
Object.entries(procedures).flatMap(([path, procedure]) => {
const type = procedure["_def"]?.type;
return type === "query" || type === "mutation" ? [[path, type] as const] : [];
}),
);
};
const waitForDemoChunkAsync = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
const createDemoAssistantResponse = (request: z.infer<typeof requestSchema>) => {
@@ -506,15 +493,24 @@ export async function POST(request: Request) {
}
const canAuthorCustomWidgets = session.user.permissions.includes("admin");
const customWidgetAuthoringContext = getCustomWidgetAuthoringContext(incomingMessages, canAuthorCustomWidgets);
const caller = mcpRouter.createCaller(context);
const procedureTypes = getProcedureTypeMap();
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 omittedCustomWidgetTools = new Set<string>(customWidgetAuthoringContext.omittedToolNames);
const mcpTools = extractMcpTools().filter((mcpTool) => !omittedCustomWidgetTools.has(mcpTool.name));
const mcpTools = mcpRuntime.tools.filter((mcpTool) => !omittedCustomWidgetTools.has(mcpTool.name));
const customWidgetComponentDocumentBudget = createCustomWidgetComponentDocumentBudget();
const homarrTools = Object.fromEntries(
mcpTools.map((mcpTool) => {
const requiresApproval = procedureTypes.get(mcpTool.pathInRouter.join(".")) === "mutation";
const requiresApproval = mcpRuntime.procedureTypes.get(mcpTool.pathInRouter.join(".")) === "mutation";
return [
mcpTool.name,
tool({
@@ -563,7 +559,7 @@ export async function POST(request: Request) {
) satisfies ToolSet;
const toolApproval = Object.fromEntries(
mcpTools.flatMap((mcpTool) =>
procedureTypes.get(mcpTool.pathInRouter.join(".")) === "mutation"
mcpRuntime.procedureTypes.get(mcpTool.pathInRouter.join(".")) === "mutation"
? [[mcpTool.name, "user-approval" as const]]
: [],
),
@@ -16,7 +16,6 @@ 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",
@@ -42,26 +41,30 @@ vi.mock("@homarr/custom-widgets/core", () => ({
vi.mock("@homarr/db", () => ({ db: {} }));
vi.mock("~/versions/package-reader", () => ({ getPackageVersion: () => "test-version" }));
vi.mock("../_extract-tools", () => ({
extractMcpTools: () => [
{
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: {} },
},
],
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: {} },
},
],
}),
}));
import { POST } from "./route";
+51 -7
View File
@@ -1,11 +1,55 @@
import { extractMcpToolsFromProcedures, mcpRouter } from "@homarr/api/mcp";
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";
let cache: ReturnType<typeof extractMcpToolsFromProcedures> | null = null;
const logger = createLogger({ module: "mcpTools" });
export function extractMcpTools() {
if (cache) return cache;
export type McpProcedureType = "query" | "mutation";
cache = extractMcpToolsFromProcedures(mcpRouter);
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;
}
+18 -16
View File
@@ -5,11 +5,12 @@ import { ResourceTemplate } from "@modelcontextprotocol/server";
import { z } from "zod/v4";
import type { McpTool } from "@homarr/api/mcp";
import { createTRPCContext, mcpRouter } from "@homarr/api/mcp";
import { createTRPCContext } 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,
@@ -22,7 +23,7 @@ import { db } from "@homarr/db";
import { getPackageVersion } from "~/versions/package-reader";
import { getSafeAssistantToolError } from "../assistant/chat/assistant-tool-error";
import { extractMcpTools } from "./_extract-tools";
import { getMcpRuntimeAsync } from "./_extract-tools";
import { createMcpProtocolHandler } from "./_protocol";
const logger = createLogger({ module: "mcpRoute" });
@@ -80,18 +81,8 @@ function clearAuthFailures(ip: string | null) {
authFailures.delete(ip ?? "unknown");
}
let toolsCache: McpTool[] | null = null;
function getTools() {
if (!toolsCache) {
toolsCache = extractMcpTools();
logger.info(`Extracted ${toolsCache.length} MCP tools`);
}
return toolsCache;
}
const getVisibleTools = (isAdmin: boolean) =>
isAdmin ? getTools() : getTools().filter((tool) => !tool.name.startsWith("customWidget_"));
const getVisibleTools = (tools: McpTool[], isAdmin: boolean) =>
isAdmin ? tools : tools.filter((tool) => !tool.name.startsWith("customWidget_"));
function registerCustomWidgetAuthoring(server: McpServer) {
server.registerPrompt(
@@ -331,12 +322,23 @@ 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 = mcpRouter.createCaller(ctx);
const caller = mcpRuntime.router.createCaller(ctx);
const isAdmin = session.user.permissions.includes("admin");
const protocolHandler = createMcpProtocolHandler({
caller,
tools: getVisibleTools(isAdmin),
tools: getVisibleTools(mcpRuntime.tools, isAdmin),
version: getPackageVersion(),
instructions: SERVER_INSTRUCTIONS,
configureServer: isAdmin ? registerCustomWidgetAuthoring : undefined,