refactor: streamline widget and integration architecture
This commit is contained in:
1 parent
ec295fe26e
commit
425cb653a1
243 files changed
+5722
-2255
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, 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";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user