fix(custom-widgets): address v2 merge review (#6446)
This commit is contained in:
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";
|
||||
|
||||
Reference in new issue
Block a user