feat(custom-widgets): complete Custom JSX v2 (#6390)
This commit is contained in:
1 parent
3a1245cbc5
commit
acdfcbb18e
416 files changed
+37853
-18066
No files matched your search
@@ -0,0 +1,18 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { CUSTOM_WIDGET_MCP_AUTHORING_PROMPT } from "@homarr/custom-widgets/authoring-prompt";
|
||||
|
||||
export const dynamic = "force-static";
|
||||
|
||||
export function GET() {
|
||||
return NextResponse.json({
|
||||
version: 2,
|
||||
prompt: CUSTOM_WIDGET_MCP_AUTHORING_PROMPT,
|
||||
resources: [
|
||||
"homarr://custom-widgets/schema",
|
||||
"homarr://custom-widgets/components",
|
||||
"homarr://custom-widgets/skill",
|
||||
],
|
||||
httpResources: ["/api/custom-widgets/schema", "/api/custom-widgets/components", "/api/custom-widgets/skill"],
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { getCustomWidgetComponentCatalog } from "@homarr/custom-widgets/authoring-resources";
|
||||
|
||||
export const dynamic = "force-static";
|
||||
|
||||
export function GET() {
|
||||
return NextResponse.json(getCustomWidgetComponentCatalog());
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
import type { NextRequest } from "next/server";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import {
|
||||
claimCustomWidgetConfigurationRequest,
|
||||
completeCustomWidgetConfigurationRequest,
|
||||
configurePreviewSessionSource,
|
||||
getCustomWidgetConfigurationRequest,
|
||||
parseStoredCustomWidgetDefinition,
|
||||
releaseCustomWidgetConfigurationRequest,
|
||||
serializeCustomWidgetDefinition,
|
||||
} from "@homarr/api/custom-widget-configuration";
|
||||
import { encryptSecret } from "@homarr/common/server";
|
||||
import { invalidateCustomWidgetResponseCache } from "@homarr/custom-widgets/server";
|
||||
import { customWidgetSourceSchema, hasSameCustomWidgetSourceAuthentication } from "@homarr/custom-widgets/core";
|
||||
import { and, db, eq, handleTransactionsAsync } from "@homarr/db";
|
||||
import { customWidgetDefinitions, customWidgetSecrets } from "@homarr/db/schema";
|
||||
|
||||
interface RouteContext {
|
||||
params: Promise<{ token: string }>;
|
||||
}
|
||||
|
||||
export async function GET(_request: NextRequest, context: RouteContext) {
|
||||
const { token } = await context.params;
|
||||
const request = await getCustomWidgetConfigurationRequest(token);
|
||||
if (!request)
|
||||
return NextResponse.json({ error: "This source configuration request is invalid or expired." }, { status: 404 });
|
||||
return NextResponse.json(
|
||||
{
|
||||
widgetName: request.widgetName,
|
||||
sourceName: request.sourceName,
|
||||
kinds: request.kinds,
|
||||
source: request.source,
|
||||
expiresAt: request.expiresAt,
|
||||
status: request.status,
|
||||
},
|
||||
{ headers: { "Cache-Control": "no-store" } },
|
||||
);
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest, context: RouteContext) {
|
||||
const { token } = await context.params;
|
||||
const pending = await getCustomWidgetConfigurationRequest(token);
|
||||
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 sourceResult = customWidgetSourceSchema.safeParse({
|
||||
...pending.source,
|
||||
baseUrl: body?.baseUrl,
|
||||
networkScope: body?.networkScope,
|
||||
});
|
||||
if (!sourceResult.success) {
|
||||
return NextResponse.json(
|
||||
{ error: sourceResult.error.issues[0]?.message ?? "Enter a valid server URL." },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
const secrets = pending.kinds.flatMap((kind) => {
|
||||
const value = body?.secrets?.[kind];
|
||||
return typeof value === "string" && value.length > 0 && value.length <= 8192
|
||||
? [{ sourceId: pending.sourceId, kind, value }]
|
||||
: [];
|
||||
});
|
||||
if (secrets.length !== pending.kinds.length) {
|
||||
return NextResponse.json({ error: "Enter every requested credential field." }, { status: 400 });
|
||||
}
|
||||
|
||||
const claimed = await claimCustomWidgetConfigurationRequest(token);
|
||||
if (!claimed) {
|
||||
return NextResponse.json(
|
||||
{ error: "This source configuration request is already being completed." },
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
if (claimed.target.type === "preview") {
|
||||
await configurePreviewSessionSource(
|
||||
claimed.target.id,
|
||||
claimed.userId,
|
||||
claimed.sourceId,
|
||||
sourceResult.data,
|
||||
secrets,
|
||||
);
|
||||
invalidateCustomWidgetResponseCache([`custom-jsx:preview:${claimed.target.id}:`]);
|
||||
} else {
|
||||
const definition = await db.query.customWidgetDefinitions.findFirst({
|
||||
where: eq(customWidgetDefinitions.id, claimed.target.id),
|
||||
});
|
||||
if (!definition) return NextResponse.json({ error: "The custom widget no longer exists." }, { status: 404 });
|
||||
const parsed = parseStoredCustomWidgetDefinition(definition);
|
||||
const currentSource = parsed.sources[claimed.sourceId];
|
||||
if (!currentSource) {
|
||||
return NextResponse.json(
|
||||
{ error: "This API source changed after the setup link was created. Request a new setup link." },
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
if (!hasSameCustomWidgetSourceAuthentication(currentSource, claimed.source)) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "This API source authentication changed after the setup link was created. Request a new setup link.",
|
||||
},
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
const configuredSource = customWidgetSourceSchema.parse({
|
||||
...currentSource,
|
||||
baseUrl: sourceResult.data.baseUrl,
|
||||
networkScope: sourceResult.data.networkScope,
|
||||
});
|
||||
const definitionChanges = {
|
||||
...serializeCustomWidgetDefinition({
|
||||
...parsed,
|
||||
sources: { ...parsed.sources, [claimed.sourceId]: configuredSource },
|
||||
}),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
const secretRows = secrets.map((secret) => ({
|
||||
definitionId: claimed.target.id,
|
||||
sourceId: secret.sourceId,
|
||||
kind: secret.kind,
|
||||
encryptedValue: encryptSecret(secret.value),
|
||||
updatedAt: new Date(),
|
||||
}));
|
||||
await handleTransactionsAsync(db, {
|
||||
async handleAsync(database, schema) {
|
||||
await database.transaction(async (transaction) => {
|
||||
await transaction
|
||||
.update(schema.customWidgetDefinitions)
|
||||
.set(definitionChanges)
|
||||
.where(eq(schema.customWidgetDefinitions.id, claimed.target.id));
|
||||
for (const secret of secretRows) {
|
||||
await transaction
|
||||
.delete(schema.customWidgetSecrets)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.customWidgetSecrets.definitionId, claimed.target.id),
|
||||
eq(schema.customWidgetSecrets.sourceId, secret.sourceId),
|
||||
eq(schema.customWidgetSecrets.kind, secret.kind),
|
||||
),
|
||||
);
|
||||
await transaction.insert(schema.customWidgetSecrets).values(secret);
|
||||
}
|
||||
});
|
||||
},
|
||||
handleSync(database) {
|
||||
database.transaction((transaction) => {
|
||||
transaction
|
||||
.update(customWidgetDefinitions)
|
||||
.set(definitionChanges)
|
||||
.where(eq(customWidgetDefinitions.id, claimed.target.id))
|
||||
.run();
|
||||
for (const secret of secretRows) {
|
||||
transaction
|
||||
.delete(customWidgetSecrets)
|
||||
.where(
|
||||
and(
|
||||
eq(customWidgetSecrets.definitionId, claimed.target.id),
|
||||
eq(customWidgetSecrets.sourceId, secret.sourceId),
|
||||
eq(customWidgetSecrets.kind, secret.kind),
|
||||
),
|
||||
)
|
||||
.run();
|
||||
transaction.insert(customWidgetSecrets).values(secret).run();
|
||||
}
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
await completeCustomWidgetConfigurationRequest(token);
|
||||
return NextResponse.json({ status: "completed" }, { headers: { "Cache-Control": "no-store" } });
|
||||
} finally {
|
||||
await releaseCustomWidgetConfigurationRequest(token);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { getCustomWidgetJsonSchema } from "@homarr/custom-widgets/core";
|
||||
|
||||
export const dynamic = "force-static";
|
||||
|
||||
export function GET() {
|
||||
return NextResponse.json(getCustomWidgetJsonSchema());
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { getCustomWidgetSkill } from "@homarr/custom-widgets/authoring-resources";
|
||||
|
||||
export const dynamic = "force-static";
|
||||
|
||||
export function GET() {
|
||||
return NextResponse.json(getCustomWidgetSkill());
|
||||
}
|
||||
@@ -3,13 +3,28 @@ import type { NextRequest } from "next/server";
|
||||
import { userAgent } from "next/server";
|
||||
import { createMcpHandler } from "mcp-handler";
|
||||
import type { McpTool } from "trpc-to-mcp";
|
||||
import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
|
||||
import {
|
||||
CallToolRequestSchema,
|
||||
GetPromptRequestSchema,
|
||||
ListPromptsRequestSchema,
|
||||
ListResourcesRequestSchema,
|
||||
ListResourceTemplatesRequestSchema,
|
||||
ListToolsRequestSchema,
|
||||
ReadResourceRequestSchema,
|
||||
} from "@modelcontextprotocol/sdk/types.js";
|
||||
|
||||
import { createTRPCContext, mcpRouter } 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 { buildCustomWidgetMcpPrompt } from "@homarr/custom-widgets/authoring-prompt";
|
||||
import {
|
||||
getCustomWidgetComponent,
|
||||
getCustomWidgetComponentCatalog,
|
||||
getCustomWidgetSkillContent,
|
||||
} from "@homarr/custom-widgets/authoring-resources";
|
||||
import { customJsxExamples, getCustomWidgetJsonSchema } from "@homarr/custom-widgets/core";
|
||||
import { db } from "@homarr/db";
|
||||
|
||||
import { getPackageVersion } from "~/versions/package-reader";
|
||||
@@ -168,6 +183,91 @@ If \`hasUseAccess\` is false, the API key owner lacks permission for that integr
|
||||
|
||||
const mcpHandler = createMcpHandler(
|
||||
(server) => {
|
||||
server.server.setRequestHandler(ListPromptsRequestSchema, () => ({
|
||||
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 },
|
||||
],
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
server.server.setRequestHandler(GetPromptRequestSchema, (request) => {
|
||||
if (request.params.name !== "homarr-custom-widget-author") throw new Error("Prompt not found");
|
||||
return {
|
||||
description: "Current Homarr Custom Widget authoring workflow.",
|
||||
messages: [
|
||||
{
|
||||
role: "user" as const,
|
||||
content: {
|
||||
type: "text" as const,
|
||||
text: buildCustomWidgetMcpPrompt(
|
||||
request.params.arguments?.request,
|
||||
request.params.arguments?.documentationUrl,
|
||||
),
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
});
|
||||
|
||||
server.server.setRequestHandler(ListResourcesRequestSchema, () => ({
|
||||
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" },
|
||||
...customJsxExamples.map((example) => ({
|
||||
uri: `homarr://custom-widgets/examples/${example.id}`,
|
||||
name: `Custom Widget example: ${example.title}`,
|
||||
mimeType: "application/json",
|
||||
})),
|
||||
],
|
||||
}));
|
||||
|
||||
server.server.setRequestHandler(ListResourceTemplatesRequestSchema, () => ({
|
||||
resourceTemplates: [
|
||||
{
|
||||
uriTemplate: "homarr://custom-widgets/components/{name}",
|
||||
name: "Custom Widget component",
|
||||
description: "Metadata for one installed Custom Widget component.",
|
||||
mimeType: "application/json",
|
||||
},
|
||||
{
|
||||
uriTemplate: "homarr://custom-widgets/examples/{name}",
|
||||
name: "Custom Widget example",
|
||||
description: "One canonical Custom Widget example.",
|
||||
mimeType: "application/json",
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
server.server.setRequestHandler(ReadResourceRequestSchema, (request) => {
|
||||
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());
|
||||
else if (uri === "homarr://custom-widgets/skill") {
|
||||
text = getCustomWidgetSkillContent();
|
||||
mimeType = "text/markdown";
|
||||
} else if (uri.startsWith("homarr://custom-widgets/components/")) {
|
||||
const name = decodeURIComponent(uri.slice("homarr://custom-widgets/components/".length));
|
||||
const component = getCustomWidgetComponent(name);
|
||||
if (!component) throw new Error("Component resource not found");
|
||||
text = JSON.stringify(component);
|
||||
} else if (uri.startsWith("homarr://custom-widgets/examples/")) {
|
||||
const name = decodeURIComponent(uri.slice("homarr://custom-widgets/examples/".length));
|
||||
const example = customJsxExamples.find((candidate) => candidate.id === name);
|
||||
if (!example) throw new Error("Example resource not found");
|
||||
text = JSON.stringify(example);
|
||||
} else throw new Error("Resource not found");
|
||||
return { contents: [{ uri, mimeType, text }] };
|
||||
});
|
||||
|
||||
server.server.setRequestHandler(ListToolsRequestSchema, () => ({
|
||||
tools: getTools(),
|
||||
}));
|
||||
@@ -219,7 +319,7 @@ const mcpHandler = createMcpHandler(
|
||||
});
|
||||
},
|
||||
{
|
||||
capabilities: { tools: {} },
|
||||
capabilities: { tools: {}, prompts: {}, resources: {} },
|
||||
instructions: SERVER_INSTRUCTIONS,
|
||||
serverInfo: {
|
||||
name: "homarr",
|
||||
|
||||
Reference in new issue
Block a user