fix(custom-widgets): address v2 merge review (#6446)

This commit is contained in:
Thomas authored and GitHub committed 2026-07-23 01:55:15 +02:00
1 parent 6aceb2fd1f
commit df967751f6
123 files changed
+8632 -1477

No files matched your search

@@ -16,6 +16,8 @@ import { customWidgetSourceSchema, hasSameCustomWidgetSourceAuthentication } fro
import { and, db, eq, handleTransactionsAsync } from "@homarr/db";
import { customWidgetDefinitions, customWidgetSecrets } from "@homarr/db/schema";
import { readConfigurationRequestBody } from "../body";
interface RouteContext {
params: Promise<{ token: string }>;
}
@@ -44,15 +46,18 @@ export async function POST(request: NextRequest, context: RouteContext) {
if (!pending || pending.status !== "pending") {
return NextResponse.json({ error: "This credential request is invalid, completed, or expired." }, { status: 404 });
}
const body = (await request.json().catch(() => null)) as {
baseUrl?: unknown;
networkScope?: unknown;
secrets?: Record<string, unknown>;
} | null;
const parsedBody = await readConfigurationRequestBody(request);
if (parsedBody.status === "too-large") {
return NextResponse.json({ error: "The configuration payload is too large." }, { status: 413 });
}
if (parsedBody.status !== "ok") {
return NextResponse.json({ error: "Enter a valid source configuration." }, { status: 400 });
}
const body = parsedBody.data;
const sourceResult = customWidgetSourceSchema.safeParse({
...pending.source,
baseUrl: body?.baseUrl,
networkScope: body?.networkScope,
baseUrl: body.baseUrl,
networkScope: body.networkScope,
});
if (!sourceResult.success) {
return NextResponse.json(
@@ -61,7 +66,7 @@ export async function POST(request: NextRequest, context: RouteContext) {
);
}
const secrets = pending.kinds.flatMap((kind) => {
const value = body?.secrets?.[kind];
const value = body.secrets[kind];
return typeof value === "string" && value.length > 0 && value.length <= 8192
? [{ sourceId: pending.sourceId, kind, value }]
: [];
@@ -0,0 +1,36 @@
import { describe, expect, it } from "vitest";
import { MAX_CONFIGURATION_REQUEST_BODY_BYTES, readConfigurationRequestBody } from "./body";
describe("custom widget configuration request body", () => {
it("rejects a streaming payload once it exceeds the byte budget", async () => {
const request = new Request("http://localhost/configure", {
method: "POST",
body: JSON.stringify({
baseUrl: "https://example.com",
networkScope: "public",
secrets: { apiKey: "x".repeat(MAX_CONFIGURATION_REQUEST_BODY_BYTES) },
}),
});
await expect(readConfigurationRequestBody(request)).resolves.toEqual({ status: "too-large" });
});
it("strictly parses a bounded configuration object", async () => {
const body = { baseUrl: "https://example.com", networkScope: "public", secrets: { apiKey: "secret" } };
await expect(
readConfigurationRequestBody(
new Request("http://localhost/configure", { method: "POST", body: JSON.stringify(body) }),
),
).resolves.toEqual({ status: "ok", data: body });
await expect(
readConfigurationRequestBody(
new Request("http://localhost/configure", {
method: "POST",
body: JSON.stringify({ ...body, unexpected: true }),
}),
),
).resolves.toEqual({ status: "invalid" });
});
});
@@ -0,0 +1,52 @@
import { z } from "zod/v4";
export const MAX_CONFIGURATION_REQUEST_BODY_BYTES = 24 * 1024;
const configurationRequestBodySchema = z.strictObject({
baseUrl: z.string(),
networkScope: z.enum(["public", "private", "loopback"]),
secrets: z.record(z.string(), z.string()),
});
export async function readConfigurationRequestBody(
request: Request,
): Promise<
{ status: "ok"; data: z.infer<typeof configurationRequestBodySchema> } | { status: "invalid" | "too-large" }
> {
const contentLength = request.headers.get("content-length");
if (contentLength && Number(contentLength) > MAX_CONFIGURATION_REQUEST_BODY_BYTES) {
return { status: "too-large" };
}
if (!request.body) return { status: "invalid" };
const reader = request.body.getReader();
const chunks: Uint8Array[] = [];
let byteLength = 0;
try {
for (;;) {
const { done, value } = await reader.read();
if (done) break;
byteLength += value.byteLength;
if (byteLength > MAX_CONFIGURATION_REQUEST_BODY_BYTES) {
await reader.cancel();
return { status: "too-large" };
}
chunks.push(value);
}
} catch {
return { status: "invalid" };
}
const bytes = new Uint8Array(byteLength);
let offset = 0;
for (const chunk of chunks) {
bytes.set(chunk, offset);
offset += chunk.byteLength;
}
try {
const candidate = JSON.parse(new TextDecoder().decode(bytes)) as unknown;
const result = configurationRequestBodySchema.safeParse(candidate);
return result.success ? { status: "ok", data: result.data } : { status: "invalid" };
} catch {
return { status: "invalid" };
}
}
@@ -0,0 +1,196 @@
// @vitest-environment node
import { beforeEach, describe, expect, test, vi } from "vitest";
const mocks = vi.hoisted(() => ({
authenticate: vi.fn(),
buildPrompt: vi.fn((request?: string, documentationUrl?: string) =>
[`Request: ${request ?? ""}`, `Documentation: ${documentationUrl ?? ""}`].join("\n"),
),
getComponent: vi.fn((name: string) => (name === "Text Input" ? { name, safety: "allowed" } : null)),
}));
vi.mock("next/server", () => ({ userAgent: () => ({ ua: "MCP route test" }) }));
vi.mock("@homarr/api/mcp", () => ({
createTRPCContext: vi.fn(() => ({})),
mcpRouter: { createCaller: vi.fn(() => ({})) },
}));
vi.mock("@homarr/auth/api-key", () => ({
API_KEY_HEADER_NAME: "ApiKey",
getSessionFromApiKeyAsync: mocks.authenticate,
}));
vi.mock("@homarr/common", () => ({ extractBaseUrlFromHeaders: () => "http://localhost" }));
vi.mock("@homarr/common/server", () => ({ ipAddressFromHeaders: () => "127.0.0.1" }));
vi.mock("@homarr/core/infrastructure/logs", () => ({
createLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn() }),
}));
vi.mock("@homarr/custom-widgets/authoring-prompt", () => ({ buildCustomWidgetMcpPrompt: mocks.buildPrompt }));
vi.mock("@homarr/custom-widgets/authoring-resources", () => ({
getCustomWidgetComponent: mocks.getComponent,
getCustomWidgetComponentCatalog: () => ({ components: [{ name: "Text Input" }] }),
getCustomWidgetSkillContent: () => "# Custom Widget Skill",
}));
vi.mock("@homarr/custom-widgets/core", () => ({
customJsxExamples: [{ id: "status-card", title: "Status card", template: "<Text>Status</Text>" }],
getCustomWidgetJsonSchema: () => ({ type: "object", title: "Custom Widget" }),
}));
vi.mock("@homarr/db", () => ({ db: {} }));
vi.mock("~/versions/package-reader", () => ({ getPackageVersion: () => "test-version" }));
vi.mock("../_extract-tools", () => ({ extractMcpTools: () => [] }));
import { POST } from "./route";
interface JsonRpcResponse {
jsonrpc: "2.0";
id: number | null;
result?: Record<string, unknown>;
error?: { code: number; message: string };
}
beforeEach(() => {
mocks.authenticate.mockResolvedValue({ user: { id: "user-1" } });
mocks.buildPrompt.mockClear();
mocks.getComponent.mockClear();
});
async function callMcp(method: string, params: Record<string, unknown> = {}, id = 1) {
const request = new Request("http://localhost/api/mcp/mcp", {
method: "POST",
headers: {
Accept: "application/json, text/event-stream",
ApiKey: "key.secret",
"Content-Type": "application/json",
},
body: JSON.stringify({ jsonrpc: "2.0", id, method, params }),
});
const response = await POST(request as never);
const text = await response.text();
const data = response.headers.get("content-type")?.includes("text/event-stream")
? text
.split("\n")
.find((line) => line.startsWith("data: "))
?.slice("data: ".length)
: text;
if (!data) throw new Error(`MCP response did not contain JSON-RPC data: ${text}`);
return { response, body: JSON.parse(data) as JsonRpcResponse };
}
describe("authenticated MCP prompt protocol", () => {
test("initializes and advertises prompt and resource capabilities", async () => {
const { response, body } = await callMcp("initialize", {
protocolVersion: "2025-03-26",
capabilities: {},
clientInfo: { name: "route-test", version: "1.0.0" },
});
expect(response.status).toBe(200);
expect(body.error).toBeUndefined();
expect(body.result).toMatchObject({
protocolVersion: expect.any(String),
capabilities: { prompts: {}, resources: {} },
serverInfo: { name: "homarr", version: "test-version" },
});
expect(mocks.authenticate).toHaveBeenCalledWith({}, "key.secret", "127.0.0.1", "MCP route test");
});
test("lists and interpolates the custom-widget authoring prompt", async () => {
const listed = await callMcp("prompts/list");
expect(listed.body.result).toEqual({
prompts: [
{
name: "homarr-custom-widget-author",
description: "Author and iterate on one Homarr Custom JSX v2 widget.",
arguments: [
{ name: "request", description: "The widget the user wants.", required: false },
{ name: "documentationUrl", description: "External API documentation URL.", required: false },
],
},
],
});
const rendered = await callMcp("prompts/get", {
name: "homarr-custom-widget-author",
arguments: { request: "Build a status card", documentationUrl: "https://docs.example.test/api" },
});
expect(rendered.body.result).toMatchObject({
description: "Current Homarr Custom Widget authoring workflow.",
messages: [
{
role: "user",
content: {
type: "text",
text: "Request: Build a status card\nDocumentation: https://docs.example.test/api",
},
},
],
});
expect(mocks.buildPrompt).toHaveBeenCalledWith("Build a status card", "https://docs.example.test/api");
});
});
describe("authenticated MCP resource protocol", () => {
test("lists concrete resources and URI templates with reviewed MIME types", async () => {
const listed = await callMcp("resources/list");
expect(listed.body.result).toEqual({
resources: [
{ uri: "homarr://custom-widgets/schema", name: "Custom Widget schema", mimeType: "application/schema+json" },
{ uri: "homarr://custom-widgets/components", name: "Custom Widget components", mimeType: "application/json" },
{ uri: "homarr://custom-widgets/skill", name: "Custom Widget skill", mimeType: "text/markdown" },
{
uri: "homarr://custom-widgets/examples/status-card",
name: "Custom Widget example: Status card",
mimeType: "application/json",
},
],
});
const templates = await callMcp("resources/templates/list");
expect(templates.body.result).toEqual({
resourceTemplates: [
expect.objectContaining({
uriTemplate: "homarr://custom-widgets/components/{name}",
mimeType: "application/json",
}),
expect.objectContaining({
uriTemplate: "homarr://custom-widgets/examples/{name}",
mimeType: "application/json",
}),
],
});
});
test.each([
["homarr://custom-widgets/schema", "application/schema+json", { type: "object", title: "Custom Widget" }],
["homarr://custom-widgets/components", "application/json", { components: [{ name: "Text Input" }] }],
["homarr://custom-widgets/skill", "text/markdown", "# Custom Widget Skill"],
[
"homarr://custom-widgets/examples/status-card",
"application/json",
{ id: "status-card", title: "Status card", template: "<Text>Status</Text>" },
],
["homarr://custom-widgets/components/Text%20Input", "application/json", { name: "Text Input", safety: "allowed" }],
])("dispatches %s with its MIME type", async (uri, mimeType, expected) => {
const { body } = await callMcp("resources/read", { uri });
const contents = body.result?.contents as Array<{ uri: string; mimeType: string; text: string }> | undefined;
const content = contents?.[0];
expect(content).toMatchObject({ uri, mimeType });
expect(mimeType === "text/markdown" ? content?.text : JSON.parse(content?.text ?? "null")).toEqual(expected);
});
});
describe("MCP protocol errors", () => {
test.each([
["prompts/get", { name: "unknown" }],
["resources/read", { uri: "homarr://custom-widgets/unknown" }],
])("returns JSON-RPC errors for unknown %s targets", async (method, params) => {
const { body } = await callMcp(method, params);
expect(body.result).toBeUndefined();
expect(body.error).toEqual(expect.objectContaining({ code: expect.any(Number), message: expect.any(String) }));
});
test("returns a protocol error for a malformed resource request", async () => {
const { body } = await callMcp("resources/read", {});
expect(body.result).toBeUndefined();
expect(body.error).toEqual(expect.objectContaining({ code: -32603, message: expect.any(String) }));
});
});
@@ -249,8 +249,10 @@ const mcpHandler = createMcpHandler(
const { uri } = request.params;
let text: string;
let mimeType = "application/json";
if (uri === "homarr://custom-widgets/schema") text = JSON.stringify(getCustomWidgetJsonSchema());
else if (uri === "homarr://custom-widgets/components") text = JSON.stringify(getCustomWidgetComponentCatalog());
if (uri === "homarr://custom-widgets/schema") {
text = JSON.stringify(getCustomWidgetJsonSchema());
mimeType = "application/schema+json";
} else if (uri === "homarr://custom-widgets/components") text = JSON.stringify(getCustomWidgetComponentCatalog());
else if (uri === "homarr://custom-widgets/skill") {
text = getCustomWidgetSkillContent();
mimeType = "text/markdown";