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:
7 files changed
+107
-78
No files matched your search
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user