fix(open-webui): drop MCP exposure and address remaining review comments

Per ajnart's review (and CodeRabbit withdrawing its own MCP comment on the
predecessor PR), the Open WebUI chat isn't a meaningful MCP surface:
- remove the .meta({ mcp }) metadata from every openWebUi procedure

Remaining CodeRabbit comments from the predecessor PR that weren't carried over:
- processWeb: constrain the URL to http(s) and reject loopback/metadata hosts
  (private LAN ranges stay allowed for self-hosters); it is also creator-gated
- uploadFile/transcribe: bound the base64 payload and validate filename/type
- docs: align the example URL with the integration default port (8080)
- new chat: clear the composer draft so it doesn't leak into the fresh chat
- web ingestion: keep the typed URL when ingestion fails
- persistence: fall back to the default title for image-only opening turns, and
  don't persist a partial reply from a failed turn as finished history
- multimodal round-trip: make the native chat/history contracts accept the same
  text-or-parts content union as completions, and flatten (not drop) non-text
  nodes when rebuilding transcripts so attachment-backed chats aren't lost.
  Image data URLs are still kept out of persisted history by design.
This commit is contained in:
Thomas Camlong committed 2026-07-11 19:11:12 +02:00
1 parent c076a36fec
commit 3d2a2d7b55
7 files changed
+107 -78

No files matched your search

@@ -24,7 +24,7 @@ import { openWebUiWidget } from "../../widgets/open-webui";
<AddingIntegration />
:::info URL configuration
Use the root URL of your Open WebUI instance, for example `http://localhost:3000`. Homarr calls the Open WebUI API under `/api` automatically.
Use the root URL of your Open WebUI instance, for example `http://localhost:8080` (Open WebUI's default port). Homarr calls the Open WebUI API under `/api` automatically.
:::
### Secrets
+48 -51
View File
@@ -38,6 +38,47 @@ const throwIfNotCreator = (integration: { creatorId?: string | null }, userId: s
}
};
// Hosts that should never be fetched server-side. processWeb is already
// creator-gated, but we still refuse loopback and the cloud metadata endpoint so
// a stray URL can't turn Open WebUI's fetcher into an SSRF vector. Private LAN
// ranges stay allowed on purpose (self-hosters routinely ingest internal wikis).
const isBlockedWebHost = (hostname: string): boolean => {
const host = hostname.toLowerCase().replace(/^\[|\]$/g, "");
return (
host === "localhost" ||
host.endsWith(".localhost") ||
host === "0.0.0.0" ||
host === "::" ||
host === "::1" ||
host.startsWith("127.") ||
host === "169.254.169.254" || // AWS/GCP/Azure instance metadata
host === "metadata.google.internal"
);
};
// A user-provided URL for web-page ingestion: must be http(s) and not target a
// loopback/metadata host.
const webUrlSchema = z
.string()
.url()
.refine((value) => {
let parsed: URL;
try {
parsed = new URL(value);
} catch {
return false;
}
return (parsed.protocol === "http:" || parsed.protocol === "https:") && !isBlockedWebHost(parsed.hostname);
}, "URL must be an http(s) address and must not point at a loopback or metadata host");
// Roughly 18 MB of raw bytes once base64-decoded (base64 inflates by ~4/3).
// Bounds the upload/transcription payloads so a huge request can't pressure
// memory here or upstream before we even start decoding.
const MAX_BASE64_LENGTH = 25_000_000;
const boundedBase64Schema = z.string().min(1).max(MAX_BASE64_LENGTH);
const uploadFilenameSchema = z.string().min(1).max(255);
const contentTypeSchema = z.string().min(1).max(255);
const messageSchema = z.object({
role: z.enum(["system", "user", "assistant"]),
content: z.string(),
@@ -123,12 +164,6 @@ const groundMessagesAsync = async (
export const openWebUiRouter = createTRPCRouter({
getModels: publicProcedure
.meta({
mcp: {
enabled: true,
description: "List the available Open WebUI chat models for the selected integration.",
},
})
.concat(createOneIntegrationMiddleware("query", "openWebUi"))
.query(async ({ ctx }) => {
throwIfNotCreator(ctx.integration, ctx.session?.user.id);
@@ -140,12 +175,6 @@ export const openWebUiRouter = createTRPCRouter({
// Chat history exposes private user data, so we require "interact" access
// (not just "query") and verify the caller is the integration's creator.
getChats: protectedProcedure
.meta({
mcp: {
enabled: true,
description: "List the saved Open WebUI chat conversations for the selected integration.",
},
})
.concat(createOneIntegrationMiddleware("interact", "openWebUi"))
.query(async ({ ctx }) => {
throwIfNotCreator(ctx.integration, ctx.session.user.id);
@@ -158,12 +187,6 @@ export const openWebUiRouter = createTRPCRouter({
// gate them like the interactive chat paths (protected + "interact" access,
// creator-only) instead of leaving them reachable by anonymous board viewers.
getKnowledge: protectedProcedure
.meta({
mcp: {
enabled: true,
description: "List the Open WebUI knowledge bases available for the selected integration.",
},
})
.concat(createOneIntegrationMiddleware("interact", "openWebUi"))
.query(async ({ ctx }) => {
throwIfNotCreator(ctx.integration, ctx.session.user.id);
@@ -173,12 +196,6 @@ export const openWebUiRouter = createTRPCRouter({
}),
getFiles: protectedProcedure
.meta({
mcp: {
enabled: true,
description: "List the files stored in Open WebUI for the selected integration.",
},
})
.concat(createOneIntegrationMiddleware("interact", "openWebUi"))
.query(async ({ ctx }) => {
throwIfNotCreator(ctx.integration, ctx.session.user.id);
@@ -188,12 +205,6 @@ export const openWebUiRouter = createTRPCRouter({
}),
getNotes: protectedProcedure
.meta({
mcp: {
enabled: true,
description: "List the notes stored in Open WebUI for the selected integration.",
},
})
.concat(createOneIntegrationMiddleware("interact", "openWebUi"))
.query(async ({ ctx }) => {
throwIfNotCreator(ctx.integration, ctx.session.user.id);
@@ -203,12 +214,6 @@ export const openWebUiRouter = createTRPCRouter({
}),
getNote: protectedProcedure
.meta({
mcp: {
enabled: true,
description: "Get the full contents of a single Open WebUI note by id.",
},
})
.input(z.object({ noteId: z.string() }))
.concat(createOneIntegrationMiddleware("interact", "openWebUi"))
.query(async ({ ctx, input }) => {
@@ -219,12 +224,6 @@ export const openWebUiRouter = createTRPCRouter({
}),
getKnowledgeFiles: protectedProcedure
.meta({
mcp: {
enabled: true,
description: "List the files belonging to an Open WebUI knowledge base.",
},
})
.input(z.object({ knowledgeId: z.string() }))
.concat(createOneIntegrationMiddleware("interact", "openWebUi"))
.query(async ({ ctx, input }) => {
@@ -239,13 +238,7 @@ export const openWebUiRouter = createTRPCRouter({
// Ingest a web page so its content can ground later messages. Returns the
// collection to retrieve from plus a display title.
processWeb: protectedProcedure
.meta({
mcp: {
enabled: true,
description: "Ingest a web page into Open WebUI so its content can ground later chat messages.",
},
})
.input(z.object({ url: z.string().url() }))
.input(z.object({ url: webUrlSchema }))
.concat(createOneIntegrationMiddleware("interact", "openWebUi"))
.mutation(async ({ ctx, input }) => {
throwIfNotCreator(ctx.integration, ctx.session.user.id);
@@ -256,7 +249,9 @@ export const openWebUiRouter = createTRPCRouter({
// Upload a file (e.g. a document) so it can be retrieved from later. Returns
// the file id; its vector collection is `file-{id}`.
uploadFile: protectedProcedure
.input(z.object({ filename: z.string(), contentBase64: z.string(), contentType: z.string() }))
.input(
z.object({ filename: uploadFilenameSchema, contentBase64: boundedBase64Schema, contentType: contentTypeSchema }),
)
.concat(createOneIntegrationMiddleware("interact", "openWebUi"))
.mutation(async ({ ctx, input }) => {
throwIfNotCreator(ctx.integration, ctx.session.user.id);
@@ -266,7 +261,9 @@ export const openWebUiRouter = createTRPCRouter({
// Transcribe recorded audio to text for voice messages.
transcribe: protectedProcedure
.input(z.object({ filename: z.string(), contentBase64: z.string(), contentType: z.string() }))
.input(
z.object({ filename: uploadFilenameSchema, contentBase64: boundedBase64Schema, contentType: contentTypeSchema }),
)
.concat(createOneIntegrationMiddleware("interact", "openWebUi"))
.mutation(async ({ ctx, input }) => {
throwIfNotCreator(ctx.integration, ctx.session.user.id);
@@ -47,25 +47,30 @@ export const openWebUiCompletionChunkSchema = z.object({
.nullish(),
});
// A single chat message exchanged with the model / persisted in a chat.
export const openWebUiChatMessageSchema = z.object({
role: z.enum(["system", "user", "assistant"]),
content: z.string(),
});
export type OpenWebUiChatMessage = z.infer<typeof openWebUiChatMessageSchema>;
// Multimodal content part (OpenAI vision format). Used only for outbound
// completion requests so users can attach images to a message.
// Multimodal content part (OpenAI vision format). Lets a message carry text
// and/or images.
export const openWebUiContentPartSchema = z.discriminatedUnion("type", [
z.object({ type: z.literal("text"), text: z.string() }),
z.object({ type: z.literal("image_url"), image_url: z.object({ url: z.string() }) }),
]);
// Message content is either plain text or multimodal parts. Both the completion
// and the native chat/history contracts use this so an attachment-backed message
// round-trips through create/update/get without parse failures or lossy drops.
export const openWebUiMessageContentSchema = z.union([z.string(), z.array(openWebUiContentPartSchema)]);
// A single chat message exchanged with the model / persisted in a chat.
export const openWebUiChatMessageSchema = z.object({
role: z.enum(["system", "user", "assistant"]),
content: openWebUiMessageContentSchema,
});
export type OpenWebUiChatMessage = z.infer<typeof openWebUiChatMessageSchema>;
// A completion message can carry plain text or multimodal parts (text + images).
export const openWebUiCompletionMessageSchema = z.object({
role: z.enum(["system", "user", "assistant"]),
content: z.union([z.string(), z.array(openWebUiContentPartSchema)]),
content: openWebUiMessageContentSchema,
});
export type OpenWebUiCompletionMessage = z.infer<typeof openWebUiCompletionMessageSchema>;
@@ -190,7 +195,7 @@ export const openWebUiHistoryMessageSchema = z.object({
id: z.string().nullish(),
parentId: z.string().nullish(),
role: z.string().nullish(),
content: z.string().nullish(),
content: openWebUiMessageContentSchema.nullish(),
});
export const openWebUiChatSchema = z.object({
@@ -206,7 +211,7 @@ export const openWebUiChatSchema = z.object({
.array(
z.object({
role: z.string().nullish(),
content: z.string().nullish(),
content: openWebUiMessageContentSchema.nullish(),
}),
)
.nullish(),
+22 -5
View File
@@ -42,17 +42,34 @@ export const toCompletionMessages = (messages: ChatMessage[]): CompletionMessage
: { role: message.role, content: message.content },
);
type RawMessage = { role?: string | null; content?: string | null };
type ContentPart = { type?: string | null; text?: string | null };
type MessageContent = string | ContentPart[] | null | undefined;
type RawMessage = { role?: string | null; content?: MessageContent };
type HistoryMessage = RawMessage & { parentId?: string | null };
interface RawChat {
messages?: RawMessage[] | null;
history?: { currentId?: string | null; messages?: Record<string, HistoryMessage> | null } | null;
}
const toChatMessage = (role: string | null | undefined, content: string | null | undefined): ChatMessage | null =>
(role === "system" || role === "user" || role === "assistant") && typeof content === "string"
? { role, content }
: null;
// Open WebUI stores message content as either a plain string or multimodal
// parts. Flatten to display text (keeping text parts, dropping image nodes)
// rather than discarding the whole message when it isn't a bare string.
const flattenContent = (content: MessageContent): string | null => {
if (typeof content === "string") return content;
if (Array.isArray(content)) {
return content
.filter((part): part is ContentPart & { text: string } => typeof part?.text === "string")
.map((part) => part.text)
.join("");
}
return null;
};
const toChatMessage = (role: string | null | undefined, content: MessageContent): ChatMessage | null => {
if (role !== "system" && role !== "user" && role !== "assistant") return null;
const text = flattenContent(content);
return text === null ? null : { role, content: text };
};
/**
* Reconstructs the ordered conversation from an Open WebUI chat. Chats created in
+1
View File
@@ -170,6 +170,7 @@ export function Chat({ options, integrationIds, isEditMode }: WidgetComponentPro
const handleNewChat = () => {
stream.reset();
attachments.reset();
setInput("");
setError(null);
setHistoryOpened(false);
};
@@ -98,9 +98,11 @@ export function useAttachments({ integrationId, onError, errorMessages }: UseAtt
const raw = webInput.trim();
if (!raw) return;
const url = /^https?:\/\//i.test(raw) ? raw : `https://${raw}`;
setWebInput("");
try {
const document = await processWeb.mutateAsync({ integrationId, url });
// Only clear the field once ingestion succeeds so a transient error
// doesn't force the user to retype the URL.
setWebInput("");
setWebItems((previous) =>
previous.some((item) => item.collectionName === document.collectionName)
? previous
@@ -77,7 +77,12 @@ export function useChatStream({
const runPersist = async (finalMessages: ChatMessage[], session: number) => {
if (!integrationId || !model) return;
// Native history stores plain text; image data URLs are dropped on persist.
const title = finalMessages.find((message) => message.role === "user")?.content.slice(0, 80) ?? newChatTitle;
// Derive the title from the first user message that actually has text so an
// image-only opening turn still gets the fallback title instead of a blank.
const firstUserText = finalMessages
.find((message) => message.role === "user" && message.content.trim() !== "")
?.content.trim();
const title = firstUserText ? firstUserText.slice(0, 80) : newChatTitle;
const payload = {
title,
models: [model],
@@ -131,15 +136,15 @@ export function useChatStream({
setPendingContextTexts([]);
};
const finalizeAssistantMessage = (stopped = false) => {
const finalizeAssistantMessage = (skipPersist = false) => {
const finalText = streamingTextRef.current;
if (finalText.length > 0) {
const finalMessages = [...messagesRef.current, { role: "assistant" as const, content: finalText }];
setMessages(finalMessages);
messagesRef.current = finalMessages;
// A user-stopped reply is kept on screen but not written to history as a
// finished turn; a later real turn will persist the whole conversation.
if (!stopped) void persistChat(finalMessages);
// A stopped or failed reply is kept on screen but not written to history as
// a finished turn; a later real turn will persist the whole conversation.
if (!skipPersist) void persistChat(finalMessages);
}
streamingTextRef.current = "";
setStreamingText("");
@@ -165,12 +170,14 @@ export function useChatStream({
finalizeAssistantMessage();
} else if (event.type === "error") {
setError(event.message);
finalizeAssistantMessage();
// Don't persist a partial reply from a failed turn as finished history.
finalizeAssistantMessage(true);
}
},
onError: (err) => {
setError(err.message);
finalizeAssistantMessage();
// Don't persist a partial reply from a failed turn as finished history.
finalizeAssistantMessage(true);
},
},
);