feat: add authenticated integration request MCP tool (#6835)
This commit is contained in:
1 parent
c32a5317ca
commit
c39a8854aa
10 files changed
+573
-6
No files matched your search
@@ -0,0 +1,204 @@
|
||||
import { Buffer } from "node:buffer";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { constructIntegrationPermissions } from "@homarr/auth/shared";
|
||||
import { decryptSecret } from "@homarr/common/server";
|
||||
import {
|
||||
getAllTrustedCertificatesAsync,
|
||||
getTrustedCertificateHostnamesAsync,
|
||||
} from "@homarr/core/infrastructure/certificates";
|
||||
import { createCustomCheckServerIdentity } from "@homarr/core/infrastructure/http";
|
||||
import { CustomWidgetDomainError, executeCustomWidgetRequest } from "@homarr/custom-widgets/server";
|
||||
import type { CustomWidgetAuthConfig, CustomWidgetHttpRequest } from "@homarr/custom-widgets/server";
|
||||
import { eq, inArray } from "@homarr/db";
|
||||
import { groupMembers, integrations, integrationGroupPermissions, integrationUserPermissions } from "@homarr/db/schema";
|
||||
import type { IntegrationKind } from "@homarr/definitions";
|
||||
|
||||
import { protectedProcedure } from "../../trpc";
|
||||
|
||||
export const integrationRequestSchema = z.object({
|
||||
integrationId: z.string().min(1).max(128),
|
||||
method: z.enum(["GET", "POST", "PUT", "PATCH", "DELETE"]),
|
||||
path: z.string().min(1).max(8192),
|
||||
body: z.json().optional(),
|
||||
confirmed: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe("Required for DELETE after the user confirms the deletion and its effects"),
|
||||
});
|
||||
|
||||
export function resolveIntegrationRequestUrl(baseUrl: string, path: string): URL {
|
||||
// Reject parser normalization tricks as well as ordinary absolute URLs.
|
||||
// eslint-disable-next-line no-control-regex -- Reject URL parser control-character normalization.
|
||||
if (/^[a-z][a-z\d+.-]*:/i.test(path) || path.startsWith("//") || /[\\\s\u0000-\u001f\u007f]/u.test(path)) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "path must be a relative URL on the integration origin" });
|
||||
}
|
||||
try {
|
||||
const base = new URL(baseUrl);
|
||||
const target = new URL(path, base);
|
||||
if (
|
||||
!["http:", "https:"].includes(base.protocol) ||
|
||||
base.username ||
|
||||
base.password ||
|
||||
target.origin !== base.origin ||
|
||||
target.username ||
|
||||
target.password ||
|
||||
target.hash
|
||||
)
|
||||
throw new Error();
|
||||
return target;
|
||||
} catch {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "path must resolve to the integration origin without credentials or a fragment",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Match the existing adapters; never guess authentication from secret names alone.
|
||||
const requestAuth = {
|
||||
sonarr: "apiKeyHeader",
|
||||
radarr: "apiKeyHeader",
|
||||
lidarr: "apiKeyHeader",
|
||||
readarr: "apiKeyHeader",
|
||||
prowlarr: "apiKeyHeader",
|
||||
overseerr: "apiKeyHeader",
|
||||
jellyseerr: "apiKeyHeader",
|
||||
seerr: "apiKeyHeader",
|
||||
immich: "apiKeyHeader",
|
||||
slskd: "apiKeyHeader",
|
||||
homeAssistant: "bearer",
|
||||
coolify: "bearer",
|
||||
audiobookshelf: "bearer",
|
||||
speedtestTracker: "bearer",
|
||||
adGuardHome: "basic",
|
||||
nextcloud: "basic",
|
||||
} satisfies Partial<Record<IntegrationKind, string>>;
|
||||
|
||||
export function resolveIntegrationRequestAuth(
|
||||
kind: IntegrationKind,
|
||||
secrets: CustomWidgetAuthConfig["secrets"],
|
||||
): CustomWidgetAuthConfig {
|
||||
if (!(kind in requestAuth)) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "This integration's authentication is not supported by integration_request",
|
||||
});
|
||||
}
|
||||
const type = requestAuth[kind as keyof typeof requestAuth];
|
||||
let required = ["apiKey"];
|
||||
if (type === "basic") required = ["username", "password"];
|
||||
if (required.some((key) => !secrets.some((secret) => secret.kind === key && secret.value))) {
|
||||
throw new TRPCError({ code: "PRECONDITION_FAILED", message: "Integration credentials are incomplete" });
|
||||
}
|
||||
return { type, secrets };
|
||||
}
|
||||
|
||||
export function redactIntegrationResponse(data: unknown, secrets: CustomWidgetAuthConfig["secrets"]): unknown {
|
||||
const values = secrets.map(({ value }) => value).filter(Boolean);
|
||||
const username = secrets.find(({ kind }) => kind === "username")?.value;
|
||||
const password = secrets.find(({ kind }) => kind === "password")?.value;
|
||||
if (username && password) values.push(`${username}:${password}`);
|
||||
const sensitive = [
|
||||
...new Set(
|
||||
values.flatMap((value) => [
|
||||
value,
|
||||
JSON.stringify(value).slice(1, -1),
|
||||
encodeURIComponent(value),
|
||||
Buffer.from(value).toString("base64"),
|
||||
]),
|
||||
),
|
||||
].toSorted((a, b) => b.length - a.length);
|
||||
const redact = (value: string) => {
|
||||
for (const secret of sensitive) value = value.replaceAll(secret, "[REDACTED]");
|
||||
return value;
|
||||
};
|
||||
const visit = (value: unknown): unknown => {
|
||||
if (typeof value === "string") return redact(value);
|
||||
if (
|
||||
(typeof value === "number" || typeof value === "boolean" || value === null) &&
|
||||
redact(String(value)) !== String(value)
|
||||
)
|
||||
return "[REDACTED]";
|
||||
if (Array.isArray(value)) return value.map(visit);
|
||||
if (value !== null && typeof value === "object") {
|
||||
return Object.fromEntries(Object.entries(value).map(([key, child]) => [redact(key), visit(child)]));
|
||||
}
|
||||
return value;
|
||||
};
|
||||
return visit(data);
|
||||
}
|
||||
|
||||
export const integrationRequestProcedure = protectedProcedure
|
||||
.meta({
|
||||
mcp: {
|
||||
enabled: true,
|
||||
description:
|
||||
"Make an authenticated HTTP request to a configured integration using its stored credentials. Get integrationId from integration_all. Every method requires hasFullAccess because arbitrary endpoints can expose service credentials or mutate state. DELETE requires confirmed=true after user confirmation, including effects such as deleting files. Returns {status,data}, including upstream error statuses. Use the service's API documentation to choose the method, relative path and optional JSON body; consult official docs with your browsing tools, or ask the user for API docs if unavailable. Absolute URLs and redirects are rejected. 15 second deadline, 1 MiB response, 10 KiB JSON body. Supports Sonarr, Radarr, Lidarr, Readarr, Prowlarr, Overseerr, Jellyseerr, Seerr, Immich, slskd, Home Assistant, Coolify, Audiobookshelf, Speedtest Tracker, AdGuard Home and Nextcloud; other authentication schemes are rejected. Never suggest bypassing a permission denial.",
|
||||
},
|
||||
})
|
||||
.input(integrationRequestSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const groups = await ctx.db.query.groupMembers.findMany({ where: eq(groupMembers.userId, ctx.session.user.id) });
|
||||
const integration = await ctx.db.query.integrations.findFirst({
|
||||
where: eq(integrations.id, input.integrationId),
|
||||
with: {
|
||||
secrets: true,
|
||||
userPermissions: { where: eq(integrationUserPermissions.userId, ctx.session.user.id) },
|
||||
groupPermissions: {
|
||||
where: inArray(
|
||||
integrationGroupPermissions.groupId,
|
||||
groups.map(({ groupId }) => groupId),
|
||||
),
|
||||
},
|
||||
},
|
||||
});
|
||||
// Arbitrary GET endpoints can expose service credentials or mutate state.
|
||||
if (!integration || !constructIntegrationPermissions(integration, ctx.session).hasFullAccess) {
|
||||
throw new TRPCError({ code: "FORBIDDEN", message: "Missing integration permission: hasFullAccess" });
|
||||
}
|
||||
const targetUrl = resolveIntegrationRequestUrl(integration.url, input.path);
|
||||
if (input.method === "DELETE" && input.confirmed !== true) {
|
||||
throw new TRPCError({ code: "PRECONDITION_FAILED", message: "This action requires confirmation" });
|
||||
}
|
||||
if (input.method === "GET" && input.body !== undefined) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "GET requests cannot include a body" });
|
||||
}
|
||||
// Decrypt only after authorization, using the same snapshot as the URL and permissions.
|
||||
// Do not log upstream data or retain error causes.
|
||||
try {
|
||||
const secrets = integration.secrets.map(({ kind, value }) => ({ kind, value: decryptSecret(value) }));
|
||||
const auth = resolveIntegrationRequestAuth(integration.kind, secrets);
|
||||
let tls: CustomWidgetHttpRequest["tls"];
|
||||
if (targetUrl.protocol === "https:") {
|
||||
const [ca, hostnames] = await Promise.all([
|
||||
getAllTrustedCertificatesAsync(),
|
||||
getTrustedCertificateHostnamesAsync(),
|
||||
]);
|
||||
tls = { ca, checkServerIdentity: createCustomCheckServerIdentity(hostnames) };
|
||||
}
|
||||
const response = await executeCustomWidgetRequest({
|
||||
baseUrl: integration.url,
|
||||
targetUrl,
|
||||
method: input.method,
|
||||
body: JSON.stringify(input.body),
|
||||
auth,
|
||||
tls,
|
||||
networkScope: "loopback",
|
||||
kind: "action", // Disable redirects for reads and writes alike; never cache this tool.
|
||||
timeoutMs: 15_000,
|
||||
textFallback: true,
|
||||
});
|
||||
return { status: response.status, data: redactIntegrationResponse(response.data, secrets) };
|
||||
} catch (error) {
|
||||
if (error instanceof TRPCError) throw new TRPCError({ code: error.code, message: error.message });
|
||||
if (error instanceof CustomWidgetDomainError) {
|
||||
throw new TRPCError({
|
||||
code: error.code,
|
||||
message: "Integration request failed (timeout, response limit, redirect or invalid target)",
|
||||
});
|
||||
}
|
||||
throw new TRPCError({ code: "BAD_GATEWAY", message: "Integration request failed" });
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user