feat(assistant): optimize MCP and custom widget authoring

This commit is contained in:
ajnart committed 2026-08-30 18:41:05 +02:00
1 parent 8d8dec5749
commit b49f6b5631
93 files changed
+6718 -1859

No files matched your search

+2
View File
@@ -1,8 +1,10 @@
export {
getAssistantContextEntitiesAsync,
getAssistantRequestContextEntitiesAsync,
getAssistantModelsAsync,
getSelectedModelDetailsAsync,
type AssistantContextEntity,
type AssistantContextReference,
} from "./router/assistant";
export type { OpenRouterGenerationTelemetry } from "./assistant-generation-telemetry";
export { createAssistantGenerationAccessToken } from "./assistant-generation-access";
+12
View File
@@ -12,6 +12,8 @@ import { iconsRouter } from "./router/icons";
import { infoRouter } from "./router/info";
import { integrationRouter } from "./router/integration/integration-router";
import { inviteRouter } from "./router/invite";
import { kubernetesMcpRouter } from "./router/kubernetes/mcp-router";
import { searchEngineRouter } from "./router/search-engine/search-engine-router";
import { airQualityRouter } from "./router/widgets/air-quality";
import { bazarrRouter } from "./router/widgets/bazarr";
import { serverSettingsRouter } from "./router/serverSettings";
@@ -22,9 +24,13 @@ import { downloadsRouter } from "./router/widgets/downloads";
import { healthMonitoringRouter } from "./router/widgets/health-monitoring";
import { mediaRequestsRouter } from "./router/widgets/media-requests";
import { mediaServerRouter } from "./router/widgets/media-server";
import { mediaOrganizerRouter } from "./router/widgets/media-organizer";
import { patchmonRouter } from "./router/widgets/patchmon";
import { smartHomeRouter } from "./router/widgets/smart-home";
import { traefikRouter } from "./router/widgets/traefik";
import { userRouter } from "./router/user";
import { widgetSecretsRouter } from "./router/widgets/widget-secrets";
import { wudRouter } from "./router/widgets/wud";
export const mcpRouter = createTRPCRouter({
app: appRouterForApps,
@@ -36,6 +42,9 @@ export const mcpRouter = createTRPCRouter({
info: infoRouter,
integration: integrationRouter,
invite: inviteRouter,
kubernetes: kubernetesMcpRouter,
searchEngine: searchEngineRouter,
user: userRouter,
airQuality: airQualityRouter,
bazarr: bazarrRouter,
serverSettings: serverSettingsRouter,
@@ -46,7 +55,10 @@ export const mcpRouter = createTRPCRouter({
healthMonitoring: healthMonitoringRouter,
mediaRequests: mediaRequestsRouter,
mediaServer: mediaServerRouter,
mediaOrganizer: mediaOrganizerRouter,
patchmon: patchmonRouter,
smartHome: smartHomeRouter,
traefik: traefikRouter,
widgetSecrets: widgetSecretsRouter,
wud: wudRouter,
});
+106
View File
@@ -79,6 +79,8 @@ export type AssistantContextEntity = {
boardId?: string;
};
export type AssistantContextReference = Pick<AssistantContextEntity, "id" | "type">;
const modelListCache = new Map<string, { expiresAt: number; value: z.infer<typeof modelSchema>[] }>();
const customHeadersSchema = z
@@ -418,6 +420,110 @@ export const getAssistantContextEntitiesAsync = async (ctx: AssistantContext): P
];
};
export const getAssistantRequestContextEntitiesAsync = async (
ctx: AssistantContext,
references: readonly AssistantContextReference[],
): Promise<AssistantContextEntity[]> => {
const session = ctx.session;
if (!session) return [];
const referenceIds = (type: AssistantContextEntity["type"]) =>
references.filter((reference) => reference.type === type).map((reference) => reference.id);
const appIds = referenceIds("app");
const integrationIds = referenceIds("integration");
const itemIds = referenceIds("widget");
const [availableBoards, currentUser] = await Promise.all([
boardRouter.createCaller(ctx).getAllBoards(),
ctx.db.query.users.findFirst({ where: eq(users.id, session.user.id) }),
]);
const boardIds = availableBoards.map((board) => board.id);
const [availableApps, availableIntegrations, availableItems] = await Promise.all([
appIds.length > 0
? ctx.db.query.apps.findMany({
columns: { id: true, name: true, description: true, iconUrl: true },
where: inArray(apps.id, appIds),
limit: 30,
})
: [],
(async () => {
if (integrationIds.length === 0) return [];
const groupsOfCurrentUser = await ctx.db.query.groupMembers.findMany({
where: eq(groupMembers.userId, session.user.id),
});
return ctx.db.query.integrations.findMany({
columns: { id: true, name: true, kind: true },
where: inArray(integrations.id, integrationIds),
with: {
userPermissions: {
where: eq(integrationUserPermissions.userId, session.user.id),
},
groupPermissions: {
where: inArray(
integrationGroupPermissions.groupId,
groupsOfCurrentUser.map((group) => group.groupId).concat(""),
),
},
},
limit: 30,
});
})(),
itemIds.length > 0 && boardIds.length > 0
? ctx.db.query.items.findMany({
columns: { id: true, boardId: true, kind: true },
where: and(inArray(items.id, itemIds), inArray(items.boardId, boardIds)),
limit: 30,
})
: [],
]);
const homeBoardId = await getHomeIdBoardAsync(ctx.db, currentUser ?? null, ctx.deviceType);
const boardsById = new Map(availableBoards.map((board) => [board.id, board]));
return [
...availableBoards.map(
(board): AssistantContextEntity => ({
id: board.id,
type: "board",
label: board.name,
description:
board.id === homeBoardId ? "Home board" : board.isMobileHome ? "Mobile home board" : "Homarr board",
}),
),
...availableApps.map(
(app): AssistantContextEntity => ({
id: app.id,
type: "app",
label: app.name,
description: app.description ?? "Homarr app",
iconUrl: app.iconUrl,
}),
),
...availableIntegrations
.filter((integration) => constructIntegrationPermissions(integration, session).hasUseAccess)
.map(
(integration): AssistantContextEntity => ({
id: integration.id,
type: "integration",
label: integration.name,
description: `${integration.kind} integration`,
iconUrl: getIconUrl(integration.kind),
}),
),
...availableItems.flatMap((item): AssistantContextEntity[] => {
const board = boardsById.get(item.boardId);
if (!board) return [];
return [
{
id: item.id,
type: "widget",
label: `${item.kind} · ${board.name}`,
description: `${item.kind} widget on ${board.name}`,
boardId: board.id,
},
];
}),
];
};
const ownedThreadAsync = async (db: Database, threadId: string, userId: string) => {
const thread = await db.query.assistantThreads.findFirst({
where: and(eq(assistantThreads.id, threadId), eq(assistantThreads.userId, userId)),
@@ -37,7 +37,7 @@ export const creationProcedures = {
mcp: {
enabled: true,
description:
"Create a Custom JSX widget only after customWidget_validate, customWidget_previewCreate, and every returned preview query succeed. Prefer templateLines for multiline JSX. Returns a client-navigable edit link.",
"Create a Custom JSX widget without reusing a preview. Prefer customWidget_createFromPreview after full preview validation and query checks so the definition is not streamed again.",
},
})
.input(customWidgetCreateSchema)
@@ -56,7 +56,7 @@ export const creationProcedures = {
mcp: {
enabled: true,
description:
"Persist the exact tested definition from a customWidget_previewCreate session. Prefer this over resending a large widget to customWidget_create. Every query in the final preview must have a successful customWidget_previewQuery journal entry.",
"Persist the exact tested definition from a customWidget_previewCreate or customWidget_previewReviseTemplate session. Prefer this over resending a large widget to customWidget_create. Every query and action in the final preview revision must have current evidence.",
},
})
.input(z.object({ previewSessionId: z.string().min(1), targetBoardId: z.string().min(1).optional() }))
@@ -82,6 +82,27 @@ export const creationProcedures = {
message: `Test every final preview query successfully before creating the widget: ${unverifiedQueryIds.join(", ")}`,
});
}
const unverifiedActionIds = Object.entries(session.requests).flatMap(([requestId, request]) => {
if (request.kind !== "action") return [];
const verified = journal.some((entry) => {
if (
entry.kind !== "action" ||
entry.requestId !== requestId ||
entry.sessionRevision !== session.revision
) {
return false;
}
if (entry.simulated) return true;
return entry.status !== null && entry.status >= 200 && entry.status < 300;
});
return verified ? [] : [requestId];
});
if (unverifiedActionIds.length > 0) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `Test every final preview action before creating the widget: ${unverifiedActionIds.join(", ")}`,
});
}
const definition = parseCustomWidgetAuthoringInput(() =>
customWidgetDefinitionSchema.parse({
@@ -3,20 +3,49 @@ import { z } from "zod/v4";
import { CUSTOM_WIDGET_MCP_AUTHORING_PROMPT } from "@homarr/custom-widgets/authoring-prompt";
import {
CUSTOM_WIDGET_SKILL_REFERENCE_NAMES,
findCustomWidgetComponents,
getCustomWidgetComponent,
getCustomWidgetComponentCatalog,
getCustomWidgetComponents,
getCustomWidgetExample,
getCustomWidgetSharedProps,
getCustomWidgetSkill,
getCustomWidgetSkillEntrypoint,
getCustomWidgetSkillReference,
} from "@homarr/custom-widgets/authoring-resources";
import {
customWidgetAuthoringDefinitionSchema,
getCustomWidgetJsonSchema,
normalizeCustomJsxAuthoringTemplate,
normalizeCustomWidgetAuthoringDefinition,
} from "@homarr/custom-widgets/core";
import { addCustomJsxDiagnosticSourceExcerpts, validateCustomJsxTemplate } from "@homarr/custom-widgets/jsx";
import { permissionRequiredProcedure } from "../../trpc";
const customWidgetTemplateMaxLength = 50_000;
const customWidgetTemplateValidationInputSchema = z
.strictObject({
template: z.string().min(1).max(customWidgetTemplateMaxLength).optional(),
templateLines: z.array(z.string().max(10_000)).min(1).max(2_000).optional(),
})
.superRefine((input, ctx) => {
if (input.template === undefined && input.templateLines === undefined) {
ctx.addIssue({ code: "custom", message: "Provide template or templateLines" });
}
if (input.template !== undefined && input.templateLines !== undefined) {
ctx.addIssue({ code: "custom", path: ["templateLines"], message: "Provide only one template format" });
}
const joinedLength = input.templateLines?.reduce((length, line) => length + line.length + 1, 0) ?? 0;
if (joinedLength > customWidgetTemplateMaxLength) {
ctx.addIssue({
code: "custom",
path: ["templateLines"],
message: `The JSX template must contain at most ${customWidgetTemplateMaxLength} characters`,
});
}
});
export const metadataProcedures = {
schema: permissionRequiredProcedure
.requiresPermission("admin")
@@ -33,8 +62,14 @@ export const metadataProcedures = {
"homarr://custom-widgets/schema",
"homarr://custom-widgets/components",
"homarr://custom-widgets/skill",
"homarr://custom-widgets/references/{schema|runtime|security}",
],
httpResources: [
"/api/custom-widgets/schema",
"/api/custom-widgets/components",
"/api/custom-widgets/skill",
"/api/custom-widgets/reference-{schema|runtime|security}",
],
httpResources: ["/api/custom-widgets/schema", "/api/custom-widgets/components", "/api/custom-widgets/skill"],
})),
getSkill: permissionRequiredProcedure
@@ -43,10 +78,22 @@ export const metadataProcedures = {
mcp: {
enabled: true,
description:
"Load the complete Homarr Custom Widget authoring skill in one response, including SKILL.md and every bundled reference. Call this before authoring, repairing, validating, previewing, or creating a custom widget.",
"Load the compact Homarr Custom Widget authoring entrypoint and its reference index. Fetch only a named reference needed by the current design with customWidget_getReference.",
},
})
.query(() => getCustomWidgetSkill()),
.query(() => getCustomWidgetSkillEntrypoint()),
getReference: permissionRequiredProcedure
.requiresPermission("admin")
.meta({
mcp: {
enabled: true,
description:
"Load one named Custom Widget authoring reference. Use schema for manifest syntax, runtime for interactions, or security for authentication and interpreter constraints.",
},
})
.input(z.object({ name: z.enum(CUSTOM_WIDGET_SKILL_REFERENCE_NAMES) }))
.query(({ input }) => getCustomWidgetSkillReference(input.name)),
getComponentCatalog: permissionRequiredProcedure
.requiresPermission("admin")
@@ -59,13 +106,36 @@ export const metadataProcedures = {
})
.query(() => getCustomWidgetComponentCatalog()),
findComponents: permissionRequiredProcedure
.requiresPermission("admin")
.meta({
mcp: {
enabled: true,
description:
"Find a small release-matched Custom JSX component subset by name or capability. Prefer this over loading the complete catalog when the intended UI is already known.",
},
})
.input(
z.object({
query: z.string().trim().min(2).max(240),
limit: z
.number()
.int()
.min(1)
.max(16)
.default(16)
.describe("Maximum matches. Search again for a different capability when needed."),
}),
)
.query(({ input }) => findCustomWidgetComponents(input.query, input.limit)),
getComponent: permissionRequiredProcedure
.requiresPermission("admin")
.meta({
mcp: {
enabled: true,
description:
"Get the installed Custom JSX documentation, allowed props, blocked props, and accessibility requirements for one named component.",
"Get one installed Custom JSX component document to resolve a concrete repair. Prefer customWidget_getComponents for a planned set.",
},
})
.input(z.object({ name: z.string().trim().min(1).max(128).describe("A component name from the catalog.") }))
@@ -75,6 +145,26 @@ export const metadataProcedures = {
return component;
}),
getComponents: permissionRequiredProcedure
.requiresPermission("admin")
.meta({
mcp: {
enabled: true,
description:
"Get compact installed documentation for up to eight selected Custom JSX components in one batch. Full single-component details remain available for a concrete unresolved prop or repair.",
},
})
.input(
z.object({
names: z
.array(z.string().trim().min(1).max(128))
.min(1)
.max(8)
.describe("Selected component names from customWidget_findComponents."),
}),
)
.query(({ input }) => getCustomWidgetComponents(input.names)),
getSharedProps: permissionRequiredProcedure
.requiresPermission("admin")
.meta({
@@ -112,6 +202,44 @@ export const metadataProcedures = {
return { ...example, widget: { ...widget, templateLines: template.split("\n") } };
}),
validateTemplate: permissionRequiredProcedure
.requiresPermission("admin")
.meta({
mcp: {
enabled: true,
description:
"Validate only a Custom JSX template for parser, component, interpreter, and safety issues. Prefer templateLines and never pass both formats. Use this inexpensive check while drafting so the complete manifest is sent only once to preview creation.",
},
})
.input(customWidgetTemplateValidationInputSchema)
.query(({ input }) => {
const rawTemplate = input.template ?? input.templateLines?.join("\n") ?? "";
const template = normalizeCustomJsxAuthoringTemplate(rawTemplate);
const diagnostics = addCustomJsxDiagnosticSourceExcerpts(template, validateCustomJsxTemplate(template));
const valid = diagnostics.every((diagnostic) => diagnostic.severity !== "error");
const hasUnknownProp = diagnostics.some((diagnostic) =>
diagnostic.message.startsWith("UNKNOWN_MANTINE_PROP"),
);
let nextStep =
"After the manifest and template agree, send the complete definition once to customWidget_previewCreate for full validation and a testable preview.";
if (!valid) {
nextStep = "Repair the reported JSX errors, then revalidate only the corrected template before previewing.";
} else if (hasUnknownProp) {
nextStep =
"Repair unknown component props before previewing; they may be ignored by the installed release. Revalidate the corrected JSX only.";
}
return {
valid,
normalizedCharacters: rawTemplate.length - template.length,
diagnostics,
summary: {
characters: template.length,
lines: template.split("\n").length,
},
nextStep,
};
}),
validate: permissionRequiredProcedure
.requiresPermission("admin")
.meta({
@@ -16,7 +16,11 @@ export const previewActionProcedures = {
previewAction: permissionRequiredProcedure
.requiresPermission("admin")
.meta({
mcp: { enabled: true, description: "Simulate or run one named action in a custom widget preview session." },
mcp: {
enabled: true,
description:
"Test one action from the current preview revision. Supply its required params and keep preview actions simulated unless the user explicitly enabled live actions. Verify confirmation, permission, and invalidation metadata before customWidget_createFromPreview.",
},
})
.input(previewSessionRequestSchema.extend({ confirmed: z.boolean().optional() }))
.mutation(async ({ ctx, input }) => {
@@ -40,7 +44,15 @@ export const previewActionProcedures = {
durationMs: 0,
simulated: true,
});
return { ok: true, status: 0, statusText: "Simulated", data: null, simulated: true as const };
return {
sessionId: session.id,
requestId: request.id,
ok: true,
status: 0,
statusText: "Simulated",
data: null,
simulated: true as const,
};
}
if ((request.confirmation || request.method === "DELETE") && input.confirmed !== true) {
throw new TRPCError({ code: "PRECONDITION_FAILED", message: "This action requires confirmation" });
@@ -78,6 +90,8 @@ export const previewActionProcedures = {
simulated: false,
});
return {
sessionId: session.id,
requestId: request.id,
ok: response.ok,
status: response.status,
statusText: response.statusText,
@@ -5,18 +5,21 @@ import { decryptSecret } from "@homarr/common/server";
import {
collectCustomWidgetRequestReferences,
customWidgetAuthoringDefinitionSchema,
customJsxTemplateSchema,
customWidgetSecretsInputSchema,
customWidgetTemplateLinesSchema,
getCustomWidgetConfirmation,
getCustomWidgetDefaultOptions,
normalizeCustomWidgetAuthoringDefinition,
validateCustomWidgetOptions,
} from "@homarr/custom-widgets/core";
import type { CustomJsxRequest } from "@homarr/custom-widgets/core";
import { eq } from "@homarr/db";
import { customWidgetDefinitions } from "@homarr/db/schema";
import { permissionRequiredProcedure } from "../../trpc";
import { parseCustomWidgetAuthoringInput } from "./authoring-validation";
import { createPreviewSession, getPreviewSession } from "./preview-sessions";
import { createPreviewSession, getPreviewSession, revisePreviewSessionTemplate } from "./preview-sessions";
import { hasSameSecretBinding, requiredSecretKinds } from "./secret-policy";
import { parseStoredCustomWidgetDefinition } from "./stored-definition";
@@ -27,13 +30,61 @@ const previewCreateInputSchema = z.object({
options: z.record(z.string(), z.unknown()).optional(),
});
const previewReviseTemplateInputSchema = z
.strictObject({
sessionId: z.string().min(1),
expectedRevision: z.number().int().nonnegative().optional(),
template: customJsxTemplateSchema.optional(),
templateLines: customWidgetTemplateLinesSchema.optional(),
})
.superRefine((input, ctx) => {
if (input.template === undefined && input.templateLines === undefined) {
ctx.addIssue({ code: "custom", path: ["template"], message: "Provide template or templateLines" });
}
if (input.template !== undefined && input.templateLines !== undefined) {
ctx.addIssue({
code: "custom",
path: ["templateLines"],
message: "Provide template or templateLines, not both",
});
}
});
const getPreviewEvidenceChecklist = (requests: Record<string, CustomJsxRequest>, sessionId: string) => ({
queries: Object.entries(requests).flatMap(([requestId, request]) => {
if (request.kind !== "query") return [];
return [
{
requestId,
trigger: request.trigger,
parameterNames: [...collectCustomWidgetRequestReferences(request).params],
nextStep: `Call customWidget_previewQuery with sessionId '${sessionId}' and requestId '${requestId}'.`,
},
];
}),
actions: Object.entries(requests).flatMap(([requestId, request]) => {
if (request.kind !== "action") return [];
return [
{
requestId,
method: request.method,
parameterNames: [...collectCustomWidgetRequestReferences(request).params],
minimumBoardPermission: request.permission,
confirmation: getCustomWidgetConfirmation(request),
invalidates: request.invalidates ?? [],
nextStep: `Call customWidget_previewAction with sessionId '${sessionId}' and requestId '${requestId}'. Actions are simulated unless live preview actions were explicitly enabled.`,
},
];
}),
});
const previewCreateProcedure = permissionRequiredProcedure
.requiresPermission("admin")
.meta({
mcp: {
enabled: true,
description:
"Create a short-lived preview for an unsaved custom widget after validation. Prefer templateLines for multiline JSX. The result lists every query that must be tested with customWidget_previewQuery before saving.",
"Fully validate a complete Custom JSX definition and create a short-lived preview in one call. Pass definition directly as an object, never serialized JSON. Prefer templateLines for multiline JSX. The result lists every query and action that needs evidence before saving.",
},
})
.input(previewCreateInputSchema)
@@ -123,22 +174,49 @@ const previewCreateProcedure = permissionRequiredProcedure
previewSession,
previewPath,
previewUrl: new URL(previewPath, ctx.baseUrl ?? "http://localhost").toString(),
queries: Object.entries(definition.requests).flatMap(([requestId, request]) => {
if (request.kind !== "query") return [];
return [
{
requestId,
trigger: request.trigger,
parameterNames: [...collectCustomWidgetRequestReferences(request).params],
nextStep: `Call customWidget_previewQuery with sessionId '${previewSession.id}' and requestId '${requestId}'.`,
},
];
}),
...getPreviewEvidenceChecklist(definition.requests, previewSession.id),
};
});
export const previewBaseProcedures = {
previewCreate: previewCreateProcedure,
previewReviseTemplate: permissionRequiredProcedure
.requiresPermission("admin")
.meta({
mcp: {
enabled: true,
description:
"Replace only the JSX template in an existing preview after a response-driven correction. The inherited sources, requests, options, and secrets are fully revalidated without resending them. Prior evidence becomes stale, so rerun every returned query and action before persistence.",
},
})
.input(previewReviseTemplateInputSchema)
.mutation(async ({ ctx, input }) => {
const template = input.template ?? input.templateLines?.join("\n");
if (template === undefined) {
throw new TRPCError({ code: "BAD_REQUEST", message: "Provide template or templateLines" });
}
const revised = await revisePreviewSessionTemplate(
input.sessionId,
ctx.session.user.id,
template,
input.expectedRevision,
);
const session = await getPreviewSession(revised.id, ctx.session.user.id);
const previewPath = `/manage/custom-widgets/preview/${session.id}`;
return {
success: true as const,
evidenceReset: true as const,
previewSession: {
id: session.id,
revision: session.revision,
expiresAt: session.expiresAt,
liveActions: session.liveActions,
},
previewPath,
previewUrl: new URL(previewPath, ctx.baseUrl ?? "http://localhost").toString(),
...getPreviewEvidenceChecklist(session.requests, session.id),
};
}),
previewGet: permissionRequiredProcedure
.requiresPermission("admin")
.input(z.object({ sessionId: z.string() }))
@@ -146,6 +224,7 @@ export const previewBaseProcedures = {
const session = await getPreviewSession(input.sessionId, ctx.session.user.id);
return {
id: session.id,
revision: session.revision,
name: session.name,
description: session.description,
iconUrl: session.iconUrl,
@@ -28,7 +28,7 @@ export const previewQueryProcedures = {
mcp: {
enabled: true,
description:
"Execute one real API query from customWidget_previewCreate.queries and return its HTTP status plus parsed data so request paths and template bindings can be verified. Call once for every returned query before customWidget_create.",
"Execute one real API query from a preview evidence list and return its HTTP status plus parsed data so request paths and template bindings can be verified. Call once for every query in the current preview revision before customWidget_createFromPreview.",
},
})
.input(previewSessionRequestSchema)
@@ -73,6 +73,8 @@ export const previewQueryProcedures = {
simulated: false,
});
return {
sessionId: session.id,
requestId: request.id,
ok: response.ok,
status: response.status,
statusText: response.statusText,
@@ -93,6 +93,12 @@ export const createPreviewSession = (input: CreatePreviewSessionInput) => call((
export const getPreviewSession = (id: string, userId: string) => call(() => getService().get(id, userId));
export const setPreviewSessionLiveActions = (id: string, userId: string, enabled: boolean) =>
call(() => getService().setLiveActions(id, userId, enabled));
export const revisePreviewSessionTemplate = (
id: string,
userId: string,
template: string,
expectedRevision?: number,
) => call(() => getService().reviseTemplate(id, userId, template, expectedRevision));
export const appendPreviewJournal = (
session: CustomWidgetPreviewSession,
entry: Omit<CustomWidgetPreviewJournalEntry, "id" | "sessionRevision" | "timestamp">,
@@ -0,0 +1,61 @@
import { z } from "zod/v4";
import { createTRPCRouter, permissionRequiredProcedure } from "../../trpc";
const kubernetesContextInput = z.object({ contextId: z.string().min(1) });
const contextsRouter = createTRPCRouter({
getContexts: permissionRequiredProcedure
.requiresPermission("admin")
.meta({
mcp: {
enabled: true,
description:
"List configured Kubernetes contexts with availability, metrics status, and default-context metadata. Requires admin permission.",
},
})
.query(async ({ ctx }) => {
const { contextsRouter: router } = await import("./router/contexts");
return await router.createCaller(ctx).getContexts();
}),
});
const ingressesRouter = createTRPCRouter({
getIngresses: permissionRequiredProcedure
.requiresPermission("admin")
.meta({
mcp: {
enabled: true,
description:
"List Kubernetes ingress inventory for a context. Get the required contextId from kubernetes_contexts_getContexts. Requires admin permission.",
},
})
.input(kubernetesContextInput)
.query(async ({ ctx, input }) => {
const { ingressesRouter: router } = await import("./router/ingresses");
return await router.createCaller(ctx).getIngresses(input);
}),
});
const podsRouter = createTRPCRouter({
getPods: permissionRequiredProcedure
.requiresPermission("admin")
.meta({
mcp: {
enabled: true,
description:
"List Kubernetes pod inventory and inferred workload types for a context. Get the required contextId from kubernetes_contexts_getContexts. Requires admin permission.",
},
})
.input(kubernetesContextInput)
.query(async ({ ctx, input }) => {
const { podsRouter: router } = await import("./router/pods");
return await router.createCaller(ctx).getPods(input);
}),
});
export const kubernetesMcpRouter = createTRPCRouter({
contexts: contextsRouter,
ingresses: ingressesRouter,
pods: podsRouter,
});
@@ -212,6 +212,88 @@ describe("custom widget definition persistence", () => {
});
});
test("requires current-revision query and action evidence after a template-only preview revision", async () => {
const db = await prepareDatabase();
const caller = createCaller(db);
const definition = customWidgetDefinitionSchema.parse({
$schema: "homarr-custom-widget-v2",
name: "Request queue",
sources: {
default: { baseUrl: "https://example.com", networkScope: "public", auth: "none" },
},
requests: {
queue: { path: "/requests" },
approve: {
kind: "action",
method: "POST",
path: "/requests/{param:id}/approve",
confirmation: "Approve this request?",
invalidates: ["queue"],
},
},
options: {},
template:
'<Stack><Text>{data.queue?.length ?? 0} requests</Text><ActionButton requestId="approve" params={{ id: 1 }}>Approve</ActionButton></Stack>',
});
const preview = await caller.previewCreate({ definition, secrets: [] });
const createInput = { previewSessionId: preview.previewSession.id };
const initialSession = await getPreviewSession(preview.previewSession.id, userId);
await appendPreviewJournal(initialSession, {
requestId: "queue",
kind: "query",
method: "GET",
path: "/requests",
status: 200,
durationMs: 1,
simulated: false,
});
await expect(caller.createFromPreview(createInput)).rejects.toThrow(
"Test every final preview action before creating the widget: approve",
);
await appendPreviewJournal(initialSession, {
requestId: "approve",
kind: "action",
method: "POST",
path: "/requests/{param:id}/approve",
status: null,
durationMs: 0,
simulated: true,
});
const revisedTemplate = definition.template.replace("requests</Text>", "pending requests</Text>");
await caller.previewReviseTemplate({
sessionId: initialSession.id,
expectedRevision: initialSession.revision,
template: revisedTemplate,
});
await expect(caller.createFromPreview(createInput)).rejects.toThrow(
"Test every final preview query successfully before creating the widget: queue",
);
const revisedSession = await getPreviewSession(preview.previewSession.id, userId);
await appendPreviewJournal(revisedSession, {
requestId: "queue",
kind: "query",
method: "GET",
path: "/requests",
status: 200,
durationMs: 1,
simulated: false,
});
await appendPreviewJournal(revisedSession, {
requestId: "approve",
kind: "action",
method: "POST",
path: "/requests/{param:id}/approve",
status: null,
durationMs: 0,
simulated: true,
});
const created = await caller.createFromPreview(createInput);
await expect(caller.get({ id: created.id })).resolves.toMatchObject({ template: revisedTemplate });
});
test("updates a saved widget with MCP-friendly multiline templateLines", async () => {
const db = await prepareDatabase();
const caller = createCaller(db);
@@ -91,4 +91,52 @@ describe("custom widget agent preview workflow", () => {
});
expect(mocks.executeRequest).not.toHaveBeenCalled();
});
test("revises a preview template without resending or changing its manifest", async () => {
const db = createDb();
const userId = createId();
await db.insert(users).values({ id: userId });
const session = {
user: { id: userId, permissions: ["admin"], colorScheme: "light" },
expires: new Date(Date.now() + 60_000).toISOString(),
} satisfies Session;
const caller = customWidgetRouter.createCaller({ db, deviceType: undefined, session });
const { template, ...definition } = CUSTOM_WIDGET_STARTER;
const preview = await caller.previewCreate({
definition: {
...definition,
requests: { fixtures: { path: "/fixtures" } },
templateLines: template.split("\n"),
},
secrets: [],
});
const revisedTemplate = '<Stack><Text>{data.fixtures?.name}</Text><Badge>Ready</Badge></Stack>';
const revised = await caller.previewReviseTemplate({
sessionId: preview.previewSession.id,
expectedRevision: 0,
templateLines: revisedTemplate.split("\n"),
});
expect(revised).toMatchObject({
success: true,
evidenceReset: true,
previewSession: { id: preview.previewSession.id, revision: 1 },
queries: [expect.objectContaining({ requestId: "fixtures" })],
});
expect(revised).not.toHaveProperty("definition");
expect(revised).not.toHaveProperty("template");
await expect(caller.previewGet({ sessionId: preview.previewSession.id })).resolves.toMatchObject({
revision: 1,
template: revisedTemplate,
requests: [expect.objectContaining({ id: "fixtures" })],
});
await expect(
caller.previewReviseTemplate({
sessionId: preview.previewSession.id,
expectedRevision: 1,
templateLines: ['<ActionButton requestId="missing">Run</ActionButton>'],
}),
).rejects.toMatchObject({ code: "BAD_REQUEST" });
});
});
+54
View File
@@ -28,7 +28,10 @@ const MCP_TOOL_ALLOWLIST = {
"customWidget_getAuthoringPrompt",
"customWidget_getComponent",
"customWidget_getComponentCatalog",
"customWidget_getComponents",
"customWidget_findComponents",
"customWidget_getExample",
"customWidget_getReference",
"customWidget_getSharedProps",
"customWidget_getSkill",
"customWidget_legacyMigrationPrompt",
@@ -37,6 +40,7 @@ const MCP_TOOL_ALLOWLIST = {
"customWidget_previewQuery",
"customWidget_schema",
"customWidget_validate",
"customWidget_validateTemplate",
"customWidget_workshopGet",
"customWidget_workshopSearch",
"dnsHole_summary",
@@ -60,15 +64,24 @@ const MCP_TOOL_ALLOWLIST = {
"integration_searchInIntegration",
"integration_searchMediaRequests",
"invite_getAll",
"kubernetes_contexts_getContexts",
"kubernetes_ingresses_getIngresses",
"kubernetes_pods_getPods",
"mediaOrganizer_getData",
"mediaRequests_getLatestRequests",
"mediaRequests_getStats",
"mediaServer_getCurrentStreams",
"patchmon_getStats",
"serverSettings_getBoardSettings",
"serverSettings_getBranding",
"searchEngine_catalog",
"smartHome_entityDetails",
"smartHome_entityState",
"traefik_getDashboard",
"user_getAll",
"user_getById",
"widgetSecrets_getConfiguredKinds",
"wud_getStats",
],
mutation: [
"app_create",
@@ -86,6 +99,7 @@ const MCP_TOOL_ALLOWLIST = {
"board_setMobileHomeBoard",
"customWidget_delete",
"customWidget_previewAction",
"customWidget_previewReviseTemplate",
"customWidget_templatePatch",
"dnsHole_disable",
"dnsHole_enable",
@@ -105,6 +119,8 @@ const MCP_TOOL_ALLOWLIST = {
"serverSettings_updateBoardSettings",
"smartHome_executeAutomation",
"smartHome_switchEntity",
"user_changeHeaderPreferences",
"user_delete",
],
secret: [
"apiKeys_create",
@@ -118,6 +134,7 @@ const MCP_TOOL_ALLOWLIST = {
"customWidget_sourceConfigure",
"customWidget_update",
"customWidget_workshopInstall",
"user_create",
"widgetSecrets_deleteSecret",
"widgetSecrets_setSecret",
],
@@ -200,13 +217,20 @@ describe("custom widget authoring procedure access", () => {
unauthenticatedCaller.customWidget.schema(),
unauthenticatedCaller.customWidget.getAuthoringPrompt(),
unauthenticatedCaller.customWidget.getComponentCatalog(),
unauthenticatedCaller.customWidget.findComponents({ query: "search input" }),
unauthenticatedCaller.customWidget.getComponent({ name: "Stack" }),
unauthenticatedCaller.customWidget.getExample({ name: "service-dashboard" }),
unauthenticatedCaller.customWidget.getReference({ name: "runtime" }),
unauthenticatedCaller.customWidget.getSharedProps({ names: ["p"] }),
unauthenticatedCaller.customWidget.getSkill(),
unauthenticatedCaller.customWidget.list(),
unauthenticatedCaller.customWidget.validate({ widget: {} }),
unauthenticatedCaller.customWidget.validateTemplate({ template: "<Text>Hi</Text>" }),
unauthenticatedCaller.customWidget.createFromPreview({ previewSessionId: "preview" }),
unauthenticatedCaller.customWidget.previewReviseTemplate({
sessionId: "preview",
template: "<Text>Hi</Text>",
}),
unauthenticatedCaller.customWidget.secretSet({
definitionId: "widget",
secret: { sourceId: "default", kind: "apiKey", value: "secret" },
@@ -227,11 +251,18 @@ describe("custom widget authoring procedure access", () => {
nonAdminCaller.customWidget.schema(),
nonAdminCaller.customWidget.getAuthoringPrompt(),
nonAdminCaller.customWidget.getComponentCatalog(),
nonAdminCaller.customWidget.findComponents({ query: "search input" }),
nonAdminCaller.customWidget.getComponent({ name: "Stack" }),
nonAdminCaller.customWidget.getExample({ name: "service-dashboard" }),
nonAdminCaller.customWidget.getReference({ name: "runtime" }),
nonAdminCaller.customWidget.getSharedProps({ names: ["p"] }),
nonAdminCaller.customWidget.getSkill(),
nonAdminCaller.customWidget.validateTemplate({ template: "<Text>Hi</Text>" }),
nonAdminCaller.customWidget.createFromPreview({ previewSessionId: "preview" }),
nonAdminCaller.customWidget.previewReviseTemplate({
sessionId: "preview",
template: "<Text>Hi</Text>",
}),
nonAdminCaller.customWidget.secretSet({
definitionId: "widget",
secret: { sourceId: "default", kind: "apiKey", value: "secret" },
@@ -256,10 +287,33 @@ describe("custom widget authoring procedure access", () => {
await expect(adminCaller.customWidget.getSkill()).resolves.toMatchObject({
name: expect.any(String),
});
await expect(adminCaller.customWidget.getReference({ name: "runtime" })).resolves.toMatchObject({
name: "runtime",
content: expect.stringContaining("SubFetch"),
});
await expect(adminCaller.customWidget.validateTemplate({ template: "<Text>Hi</Text>" })).resolves.toMatchObject({
valid: true,
});
await expect(
adminCaller.customWidget.validateTemplate({
template:
'<Stack><SubFetch requestId="search" params={{ query: inputs.search }}}>{(result) => <Text>{result.totalResults}</Text>}</SubFetch></Stack>',
}),
).resolves.toMatchObject({
valid: false,
diagnostics: [expect.objectContaining({ sourceExcerpt: expect.stringContaining("}}>") })],
nextStep: expect.stringContaining("Repair the reported JSX errors"),
});
await expect(adminCaller.customWidget.getComponentCatalog()).resolves.toMatchObject({
components: expect.arrayContaining([expect.objectContaining({ name: "Stack" })]),
examples: expect.arrayContaining([expect.objectContaining({ id: "service-dashboard" })]),
});
await expect(adminCaller.customWidget.findComponents({ query: "TextInput SubFetch" })).resolves.toMatchObject({
components: expect.arrayContaining([
expect.objectContaining({ name: "TextInput" }),
expect.objectContaining({ name: "SubFetch" }),
]),
});
await expect(adminCaller.customWidget.getComponent({ name: "Stack" })).resolves.toMatchObject({
name: "Stack",
props: expect.anything(),