fix: harden assistant integration
This commit is contained in:
1 parent
10ef871777
commit
5db95f2def
14 files changed
+349
-189
No files matched your search
@@ -195,15 +195,22 @@ const ownedThreadAsync = async (db: Database, threadId: string, userId: string)
|
||||
};
|
||||
|
||||
export const assistantRouter = createTRPCRouter({
|
||||
getAvailability: protectedProcedure.query(async ({ ctx }) => {
|
||||
const configuration = await getConfigurationAsync(ctx.db);
|
||||
const requiresApiKey = configuration?.provider === "openrouter" || configuration?.provider === "openai";
|
||||
return {
|
||||
enabled: Boolean(
|
||||
configuration?.enabled && configuration.modelId && (!requiresApiKey || configuration.encryptedApiKey),
|
||||
),
|
||||
};
|
||||
}),
|
||||
getAvailability: protectedProcedure
|
||||
.meta({
|
||||
mcp: {
|
||||
enabled: true,
|
||||
description: "Check whether Homarr Assistant is enabled and configured for the current user",
|
||||
},
|
||||
})
|
||||
.query(async ({ ctx }) => {
|
||||
const configuration = await getConfigurationAsync(ctx.db);
|
||||
const requiresApiKey = configuration?.provider === "openrouter" || configuration?.provider === "openai";
|
||||
return {
|
||||
enabled: Boolean(
|
||||
configuration?.enabled && configuration.modelId && (!requiresApiKey || configuration.encryptedApiKey),
|
||||
),
|
||||
};
|
||||
}),
|
||||
|
||||
getAdminConfiguration: adminProcedure.query(async ({ ctx }) => {
|
||||
const configuration = await getConfigurationAsync(ctx.db);
|
||||
|
||||
@@ -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", () => ({}));
|
||||
|
||||
@@ -18,9 +19,14 @@ const MCP_TOOL_ALLOWLIST = {
|
||||
"beszel_getSystems",
|
||||
"beszel_getSystemStats",
|
||||
"board_getAllBoards",
|
||||
"board_getBoardSettings",
|
||||
"calendar_findAllEvents",
|
||||
"customWidget_get",
|
||||
"customWidget_getAuthoringPrompt",
|
||||
"customWidget_getComponent",
|
||||
"customWidget_getComponentCatalog",
|
||||
"customWidget_getExample",
|
||||
"customWidget_getSharedProps",
|
||||
"customWidget_getSkill",
|
||||
"customWidget_legacyMigrationPrompt",
|
||||
"customWidget_list",
|
||||
@@ -97,6 +103,7 @@ const MCP_TOOL_ALLOWLIST = {
|
||||
"apiKeys_delete",
|
||||
"customWidget_configurationRequestUser",
|
||||
"customWidget_create",
|
||||
"customWidget_createFromPreview",
|
||||
"customWidget_migrateLegacy",
|
||||
"customWidget_previewCreate",
|
||||
"customWidget_secretSet",
|
||||
@@ -124,7 +131,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;
|
||||
@@ -143,7 +150,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" } }
|
||||
@@ -157,7 +164,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.",
|
||||
@@ -165,6 +172,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,
|
||||
@@ -176,9 +191,14 @@ describe("custom widget authoring procedure access", () => {
|
||||
const calls = [
|
||||
unauthenticatedCaller.customWidget.schema(),
|
||||
unauthenticatedCaller.customWidget.getAuthoringPrompt(),
|
||||
unauthenticatedCaller.customWidget.getComponentCatalog(),
|
||||
unauthenticatedCaller.customWidget.getComponent({ name: "Stack" }),
|
||||
unauthenticatedCaller.customWidget.getExample({ name: "service-dashboard" }),
|
||||
unauthenticatedCaller.customWidget.getSharedProps({ names: ["p"] }),
|
||||
unauthenticatedCaller.customWidget.getSkill(),
|
||||
unauthenticatedCaller.customWidget.list(),
|
||||
unauthenticatedCaller.customWidget.validate({ widget: {} }),
|
||||
unauthenticatedCaller.customWidget.createFromPreview({ previewSessionId: "preview" }),
|
||||
unauthenticatedCaller.customWidget.secretSet({
|
||||
definitionId: "widget",
|
||||
secret: { sourceId: "default", kind: "apiKey", value: "secret" },
|
||||
@@ -198,7 +218,12 @@ describe("custom widget authoring procedure access", () => {
|
||||
const calls = [
|
||||
nonAdminCaller.customWidget.schema(),
|
||||
nonAdminCaller.customWidget.getAuthoringPrompt(),
|
||||
nonAdminCaller.customWidget.getComponentCatalog(),
|
||||
nonAdminCaller.customWidget.getComponent({ name: "Stack" }),
|
||||
nonAdminCaller.customWidget.getExample({ name: "service-dashboard" }),
|
||||
nonAdminCaller.customWidget.getSharedProps({ names: ["p"] }),
|
||||
nonAdminCaller.customWidget.getSkill(),
|
||||
nonAdminCaller.customWidget.createFromPreview({ previewSessionId: "preview" }),
|
||||
nonAdminCaller.customWidget.secretSet({
|
||||
definitionId: "widget",
|
||||
secret: { sourceId: "default", kind: "apiKey", value: "secret" },
|
||||
@@ -223,5 +248,37 @@ describe("custom widget authoring procedure access", () => {
|
||||
await expect(adminCaller.customWidget.getSkill()).resolves.toMatchObject({
|
||||
name: expect.any(String),
|
||||
});
|
||||
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.getComponent({ name: "Stack" })).resolves.toMatchObject({
|
||||
name: "Stack",
|
||||
props: expect.anything(),
|
||||
});
|
||||
await expect(adminCaller.customWidget.getExample({ name: "service-dashboard" })).resolves.toMatchObject({
|
||||
id: "service-dashboard",
|
||||
widget: expect.objectContaining({
|
||||
$schema: "homarr-custom-widget-v2",
|
||||
templateLines: expect.any(Array),
|
||||
}),
|
||||
});
|
||||
await expect(adminCaller.customWidget.getExample({ name: "pokedex" })).resolves.toMatchObject({
|
||||
id: "pokedex",
|
||||
widget: expect.objectContaining({
|
||||
name: "Pokédex",
|
||||
templateLines: expect.arrayContaining([expect.stringContaining("<Stack")]),
|
||||
}),
|
||||
});
|
||||
await expect(adminCaller.customWidget.getSharedProps({ names: ["p", "m"] })).resolves.toMatchObject({
|
||||
props: [expect.objectContaining({ name: "p" }), expect.objectContaining({ name: "m" })],
|
||||
notFound: [],
|
||||
});
|
||||
await expect(adminCaller.customWidget.getComponent({ name: "NotAComponent" })).rejects.toEqual(
|
||||
expect.objectContaining<Partial<TRPCError>>({ code: "NOT_FOUND" }),
|
||||
);
|
||||
await expect(adminCaller.customWidget.getExample({ name: "not-an-example" })).rejects.toEqual(
|
||||
expect.objectContaining<Partial<TRPCError>>({ code: "NOT_FOUND" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user