Skip to main content
Glama

Karakeep MCP server

by karakeep-app
bookmarks.ts33.4 kB
import { experimental_trpcMiddleware, TRPCError } from "@trpc/server"; import { and, eq, gt, inArray, lt, or } from "drizzle-orm"; import invariant from "tiny-invariant"; import { z } from "zod"; import type { ZBookmark, ZBookmarkContent, } from "@karakeep/shared/types/bookmarks"; import type { ZBookmarkTags } from "@karakeep/shared/types/tags"; import { db as DONT_USE_db } from "@karakeep/db"; import { assets, AssetTypes, bookmarkAssets, bookmarkLinks, bookmarks, bookmarkTags, bookmarkTexts, customPrompts, tagsOnBookmarks, } from "@karakeep/db/schema"; import { AssetPreprocessingQueue, LinkCrawlerQueue, OpenAIQueue, QuotaService, SearchIndexingQueue, triggerRuleEngineOnEvent, triggerSearchReindex, triggerWebhook, } from "@karakeep/shared-server"; import { deleteAsset, SUPPORTED_BOOKMARK_ASSET_TYPES, } from "@karakeep/shared/assetdb"; import serverConfig from "@karakeep/shared/config"; import { InferenceClientFactory } from "@karakeep/shared/inference"; import { buildSummaryPrompt } from "@karakeep/shared/prompts"; import { EnqueueOptions } from "@karakeep/shared/queueing"; import { FilterQuery, getSearchClient } from "@karakeep/shared/search"; import { parseSearchQuery } from "@karakeep/shared/searchQueryParser"; import { BookmarkTypes, DEFAULT_NUM_BOOKMARKS_PER_PAGE, zBookmarkSchema, zGetBookmarksRequestSchema, zGetBookmarksResponseSchema, zManipulatedTagSchema, zNewBookmarkRequestSchema, zSearchBookmarksCursor, zSearchBookmarksRequestSchema, zUpdateBookmarksRequestSchema, } from "@karakeep/shared/types/bookmarks"; import { normalizeTagName } from "@karakeep/shared/utils/tag"; import type { AuthedContext, Context } from "../index"; import { authedProcedure, createRateLimitMiddleware, router } from "../index"; import { mapDBAssetTypeToUserType } from "../lib/attachments"; import { getBookmarkIdsFromMatcher } from "../lib/search"; import { Bookmark } from "../models/bookmarks"; import { ImportSession } from "../models/importSessions"; import { ensureAssetOwnership } from "./assets"; export const ensureBookmarkOwnership = experimental_trpcMiddleware<{ ctx: Context; input: { bookmarkId: string }; }>().create(async (opts) => { const bookmark = await opts.ctx.db.query.bookmarks.findFirst({ where: eq(bookmarks.id, opts.input.bookmarkId), columns: { userId: true, }, }); if (!opts.ctx.user) { throw new TRPCError({ code: "UNAUTHORIZED", message: "User is not authorized", }); } if (!bookmark) { throw new TRPCError({ code: "NOT_FOUND", message: "Bookmark not found", }); } if (bookmark.userId != opts.ctx.user.id) { throw new TRPCError({ code: "FORBIDDEN", message: "User is not allowed to access resource", }); } return opts.next(); }); async function getBookmark( ctx: AuthedContext, bookmarkId: string, includeContent: boolean, ) { const bookmark = await ctx.db.query.bookmarks.findFirst({ where: and(eq(bookmarks.userId, ctx.user.id), eq(bookmarks.id, bookmarkId)), with: { tagsOnBookmarks: { with: { tag: true, }, }, link: true, text: true, asset: true, assets: true, }, }); if (!bookmark) { throw new TRPCError({ code: "NOT_FOUND", message: "Bookmark not found", }); } return await toZodSchema(bookmark, includeContent); } async function attemptToDedupLink(ctx: AuthedContext, url: string) { const result = await ctx.db .select({ id: bookmarkLinks.id, }) .from(bookmarkLinks) .leftJoin(bookmarks, eq(bookmarks.id, bookmarkLinks.id)) .where(and(eq(bookmarkLinks.url, url), eq(bookmarks.userId, ctx.user.id))); if (result.length == 0) { return null; } return getBookmark(ctx, result[0].id, /* includeContent: */ false); } async function dummyDrizzleReturnType() { const x = await DONT_USE_db.query.bookmarks.findFirst({ with: { tagsOnBookmarks: { with: { tag: true, }, }, link: true, text: true, asset: true, assets: true, }, }); if (!x) { throw new Error(); } return x; } type BookmarkQueryReturnType = Awaited< ReturnType<typeof dummyDrizzleReturnType> >; async function cleanupAssetForBookmark( bookmark: Pick<BookmarkQueryReturnType, "asset" | "userId" | "assets">, ) { const assetIds: Set<string> = new Set<string>( bookmark.assets.map((a) => a.id), ); // Todo: Remove when the bookmark asset is also in the assets table if (bookmark.asset) { assetIds.add(bookmark.asset.assetId); } await Promise.all( Array.from(assetIds).map((assetId) => deleteAsset({ userId: bookmark.userId, assetId }), ), ); } async function toZodSchema( bookmark: BookmarkQueryReturnType, includeContent: boolean, ): Promise<ZBookmark> { const { tagsOnBookmarks, link, text, asset, assets, ...rest } = bookmark; let content: ZBookmarkContent = { type: BookmarkTypes.UNKNOWN, }; if (bookmark.link) { content = { type: BookmarkTypes.LINK, screenshotAssetId: assets.find( (a) => a.assetType == AssetTypes.LINK_SCREENSHOT, )?.id, fullPageArchiveAssetId: assets.find( (a) => a.assetType == AssetTypes.LINK_FULL_PAGE_ARCHIVE, )?.id, precrawledArchiveAssetId: assets.find( (a) => a.assetType == AssetTypes.LINK_PRECRAWLED_ARCHIVE, )?.id, imageAssetId: assets.find( (a) => a.assetType == AssetTypes.LINK_BANNER_IMAGE, )?.id, videoAssetId: assets.find((a) => a.assetType == AssetTypes.LINK_VIDEO) ?.id, url: link.url, title: link.title, description: link.description, imageUrl: link.imageUrl, favicon: link.favicon, htmlContent: includeContent ? await Bookmark.getBookmarkHtmlContent(link, bookmark.userId) : null, crawledAt: link.crawledAt, author: link.author, publisher: link.publisher, datePublished: link.datePublished, dateModified: link.dateModified, }; } if (bookmark.text) { content = { type: BookmarkTypes.TEXT, // It's ok to include the text content as it's usually not big and is used to render the text bookmark card. text: text.text ?? "", sourceUrl: text.sourceUrl, }; } if (bookmark.asset) { content = { type: BookmarkTypes.ASSET, assetType: asset.assetType, assetId: asset.assetId, fileName: asset.fileName, sourceUrl: asset.sourceUrl, size: assets.find((a) => a.id == asset.assetId)?.size, content: includeContent ? asset.content : null, }; } return { tags: tagsOnBookmarks .map((t) => ({ attachedBy: t.attachedBy, ...t.tag, })) .sort((a, b) => a.attachedBy === "ai" ? 1 : b.attachedBy === "ai" ? -1 : 0, ), content, assets: assets.map((a) => ({ id: a.id, assetType: mapDBAssetTypeToUserType(a.assetType), })), ...rest, }; } export const bookmarksAppRouter = router({ createBookmark: authedProcedure .input(zNewBookmarkRequestSchema) .output( zBookmarkSchema.merge( z.object({ alreadyExists: z.boolean().optional().default(false), }), ), ) .mutation(async ({ input, ctx }) => { if (input.type == BookmarkTypes.LINK) { // This doesn't 100% protect from duplicates because of races, but it's more than enough for this usecase. const alreadyExists = await attemptToDedupLink(ctx, input.url); if (alreadyExists) { if (input.importSessionId) { const session = await ImportSession.fromId( ctx, input.importSessionId, ); await session.attachBookmark(alreadyExists.id); } return { ...alreadyExists, alreadyExists: true }; } } // Check user quota const quotaResult = await QuotaService.canCreateBookmark( ctx.db, ctx.user.id, ); if (!quotaResult.result) { throw new TRPCError({ code: "FORBIDDEN", message: quotaResult.error, }); } const bookmark = await ctx.db.transaction(async (tx) => { const bookmark = ( await tx .insert(bookmarks) .values({ userId: ctx.user.id, title: input.title, type: input.type, archived: input.archived, favourited: input.favourited, note: input.note, summary: input.summary, createdAt: input.createdAt, source: input.source, }) .returning() )[0]; let content: ZBookmarkContent; switch (input.type) { case BookmarkTypes.LINK: { const link = ( await tx .insert(bookmarkLinks) .values({ id: bookmark.id, url: input.url.trim(), }) .returning() )[0]; if (input.precrawledArchiveId) { await ensureAssetOwnership({ ctx, assetId: input.precrawledArchiveId, }); await tx .update(assets) .set({ bookmarkId: bookmark.id, assetType: AssetTypes.LINK_PRECRAWLED_ARCHIVE, }) .where( and( eq(assets.id, input.precrawledArchiveId), eq(assets.userId, ctx.user.id), ), ); } content = { type: BookmarkTypes.LINK, ...link, }; break; } case BookmarkTypes.TEXT: { const text = ( await tx .insert(bookmarkTexts) .values({ id: bookmark.id, text: input.text, sourceUrl: input.sourceUrl, }) .returning() )[0]; content = { type: BookmarkTypes.TEXT, text: text.text ?? "", sourceUrl: text.sourceUrl, }; break; } case BookmarkTypes.ASSET: { const [asset] = await tx .insert(bookmarkAssets) .values({ id: bookmark.id, assetType: input.assetType, assetId: input.assetId, content: null, metadata: null, fileName: input.fileName ?? null, sourceUrl: null, }) .returning(); const uploadedAsset = await ensureAssetOwnership({ ctx, assetId: input.assetId, }); if ( !uploadedAsset.contentType || !SUPPORTED_BOOKMARK_ASSET_TYPES.has(uploadedAsset.contentType) ) { throw new TRPCError({ code: "BAD_REQUEST", message: "Unsupported asset type", }); } await tx .update(assets) .set({ bookmarkId: bookmark.id, assetType: AssetTypes.BOOKMARK_ASSET, }) .where( and( eq(assets.id, input.assetId), eq(assets.userId, ctx.user.id), ), ); content = { type: BookmarkTypes.ASSET, assetType: asset.assetType, assetId: asset.assetId, }; break; } } return { alreadyExists: false, tags: [] as ZBookmarkTags[], assets: [], content, ...bookmark, }; }); if (input.importSessionId) { const session = await ImportSession.fromId(ctx, input.importSessionId); await session.attachBookmark(bookmark.id); } const enqueueOpts: EnqueueOptions = { // The lower the priority number, the sooner the job will be processed priority: input.crawlPriority === "low" ? 50 : 0, }; switch (bookmark.content.type) { case BookmarkTypes.LINK: { // The crawling job triggers openai when it's done await LinkCrawlerQueue.enqueue( { bookmarkId: bookmark.id, }, enqueueOpts, ); break; } case BookmarkTypes.TEXT: { await OpenAIQueue.enqueue( { bookmarkId: bookmark.id, type: "tag", }, enqueueOpts, ); break; } case BookmarkTypes.ASSET: { await AssetPreprocessingQueue.enqueue( { bookmarkId: bookmark.id, fixMode: false, }, enqueueOpts, ); break; } } await triggerRuleEngineOnEvent( bookmark.id, [ { type: "bookmarkAdded", }, ], enqueueOpts, ); await triggerSearchReindex(bookmark.id, enqueueOpts); await triggerWebhook( bookmark.id, "created", /* userId */ undefined, enqueueOpts, ); return bookmark; }), updateBookmark: authedProcedure .input(zUpdateBookmarksRequestSchema) .output(zBookmarkSchema) .use(ensureBookmarkOwnership) .mutation(async ({ input, ctx }) => { await ctx.db.transaction(async (tx) => { let somethingChanged = false; // Update link-specific fields if any are provided const linkUpdateData: Partial<{ url: string; description: string | null; author: string | null; publisher: string | null; datePublished: Date | null; dateModified: Date | null; }> = {}; if (input.url) { linkUpdateData.url = input.url.trim(); } if (input.description !== undefined) { linkUpdateData.description = input.description; } if (input.author !== undefined) { linkUpdateData.author = input.author; } if (input.publisher !== undefined) { linkUpdateData.publisher = input.publisher; } if (input.datePublished !== undefined) { linkUpdateData.datePublished = input.datePublished; } if (input.dateModified !== undefined) { linkUpdateData.dateModified = input.dateModified; } if (Object.keys(linkUpdateData).length > 0) { const result = await tx .update(bookmarkLinks) .set(linkUpdateData) .where(eq(bookmarkLinks.id, input.bookmarkId)); if (result.changes == 0) { throw new TRPCError({ code: "BAD_REQUEST", message: "Attempting to set link attributes for non-link type bookmark", }); } somethingChanged = true; } if (input.text) { const result = await tx .update(bookmarkTexts) .set({ text: input.text, }) .where(eq(bookmarkTexts.id, input.bookmarkId)); if (result.changes == 0) { throw new TRPCError({ code: "BAD_REQUEST", message: "Attempting to set link attributes for non-text type bookmark", }); } somethingChanged = true; } if (input.assetContent !== undefined) { const result = await tx .update(bookmarkAssets) .set({ content: input.assetContent, }) .where(and(eq(bookmarkAssets.id, input.bookmarkId))); if (result.changes == 0) { throw new TRPCError({ code: "BAD_REQUEST", message: "Attempting to set asset content for non-asset type bookmark", }); } somethingChanged = true; } // Update common bookmark fields const commonUpdateData: Partial<{ title: string | null; archived: boolean; favourited: boolean; note: string | null; summary: string | null; createdAt: Date; modifiedAt: Date; // Always update modifiedAt }> = { modifiedAt: new Date(), }; if (input.title !== undefined) { commonUpdateData.title = input.title; } if (input.archived !== undefined) { commonUpdateData.archived = input.archived; } if (input.favourited !== undefined) { commonUpdateData.favourited = input.favourited; } if (input.note !== undefined) { commonUpdateData.note = input.note; } if (input.summary !== undefined) { commonUpdateData.summary = input.summary; } if (input.createdAt !== undefined) { commonUpdateData.createdAt = input.createdAt; } if (Object.keys(commonUpdateData).length > 1 || somethingChanged) { await tx .update(bookmarks) .set(commonUpdateData) .where( and( eq(bookmarks.userId, ctx.user.id), eq(bookmarks.id, input.bookmarkId), ), ); } }); // Refetch the updated bookmark data to return the full object const updatedBookmark = await getBookmark( ctx, input.bookmarkId, /* includeContent: */ false, ); if (input.favourited === true || input.archived === true) { await triggerRuleEngineOnEvent( input.bookmarkId, [ ...(input.favourited === true ? ["favourited" as const] : []), ...(input.archived === true ? ["archived" as const] : []), ].map((t) => ({ type: t, })), ); } // Trigger re-indexing and webhooks await triggerSearchReindex(input.bookmarkId); await triggerWebhook(input.bookmarkId, "edited"); return updatedBookmark; }), // DEPRECATED: use updateBookmark instead updateBookmarkText: authedProcedure .input( z.object({ bookmarkId: z.string(), text: z.string(), }), ) .use(ensureBookmarkOwnership) .mutation(async ({ input, ctx }) => { await ctx.db.transaction(async (tx) => { const res = await tx .update(bookmarkTexts) .set({ text: input.text, }) .where(and(eq(bookmarkTexts.id, input.bookmarkId))) .returning(); if (res.length == 0) { throw new TRPCError({ code: "NOT_FOUND", message: "Bookmark not found", }); } await tx .update(bookmarks) .set({ modifiedAt: new Date() }) .where( and( eq(bookmarks.id, input.bookmarkId), eq(bookmarks.userId, ctx.user.id), ), ); }); await triggerSearchReindex(input.bookmarkId); await triggerWebhook(input.bookmarkId, "edited"); }), deleteBookmark: authedProcedure .input(z.object({ bookmarkId: z.string() })) .use(ensureBookmarkOwnership) .mutation(async ({ input, ctx }) => { const bookmark = await ctx.db.query.bookmarks.findFirst({ where: and( eq(bookmarks.id, input.bookmarkId), eq(bookmarks.userId, ctx.user.id), ), with: { asset: true, link: true, assets: true, }, }); const deleted = await ctx.db .delete(bookmarks) .where( and( eq(bookmarks.userId, ctx.user.id), eq(bookmarks.id, input.bookmarkId), ), ); await SearchIndexingQueue.enqueue({ bookmarkId: input.bookmarkId, type: "delete", }); await triggerWebhook(input.bookmarkId, "deleted", ctx.user.id); if (deleted.changes > 0 && bookmark) { await cleanupAssetForBookmark({ asset: bookmark.asset, userId: ctx.user.id, assets: bookmark.assets, }); } }), recrawlBookmark: authedProcedure .use( createRateLimitMiddleware({ name: "bookmarks.recrawlBookmark", windowMs: 30 * 60 * 1000, maxRequests: 200, }), ) .input( z.object({ bookmarkId: z.string(), archiveFullPage: z.boolean().optional().default(false), }), ) .use(ensureBookmarkOwnership) .mutation(async ({ input, ctx }) => { await ctx.db .update(bookmarkLinks) .set({ crawlStatus: "pending", crawlStatusCode: null, }) .where(eq(bookmarkLinks.id, input.bookmarkId)); await LinkCrawlerQueue.enqueue({ bookmarkId: input.bookmarkId, archiveFullPage: input.archiveFullPage, }); }), getBookmark: authedProcedure .input( z.object({ bookmarkId: z.string(), includeContent: z.boolean().optional().default(false), }), ) .output(zBookmarkSchema) .use(ensureBookmarkOwnership) .query(async ({ input, ctx }) => { return await getBookmark(ctx, input.bookmarkId, input.includeContent); }), searchBookmarks: authedProcedure .input(zSearchBookmarksRequestSchema) .output( z.object({ bookmarks: z.array(zBookmarkSchema), nextCursor: zSearchBookmarksCursor.nullable(), }), ) .query(async ({ input, ctx }) => { if (!input.limit) { input.limit = DEFAULT_NUM_BOOKMARKS_PER_PAGE; } const sortOrder = input.sortOrder || "relevance"; const client = await getSearchClient(); if (!client) { throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Search functionality is not configured", }); } const parsedQuery = parseSearchQuery(input.text); let filter: FilterQuery[]; if (parsedQuery.matcher) { const bookmarkIds = await getBookmarkIdsFromMatcher( ctx, parsedQuery.matcher, ); filter = [ { type: "in", field: "id", values: bookmarkIds }, { type: "eq", field: "userId", value: ctx.user.id }, ]; } else { filter = [{ type: "eq", field: "userId", value: ctx.user.id }]; } /** * preserve legacy behaviour */ const createdAtSortOrder = sortOrder === "relevance" ? "desc" : sortOrder; const resp = await client.search({ query: parsedQuery.text, filter, sort: [{ field: "createdAt", order: createdAtSortOrder }], limit: input.limit, ...(input.cursor ? { offset: input.cursor.offset, } : {}), }); if (resp.hits.length == 0) { return { bookmarks: [], nextCursor: null }; } const idToRank = resp.hits.reduce<Record<string, number>>((acc, r) => { acc[r.id] = r.score || 0; return acc; }, {}); const results = await ctx.db.query.bookmarks.findMany({ where: and( eq(bookmarks.userId, ctx.user.id), inArray( bookmarks.id, resp.hits.map((h) => h.id), ), ), with: { tagsOnBookmarks: { with: { tag: true, }, }, link: true, text: true, asset: true, assets: true, }, }); switch (true) { case sortOrder === "relevance": results.sort((a, b) => idToRank[b.id] - idToRank[a.id]); break; case sortOrder === "desc": results.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()); break; case sortOrder === "asc": results.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime()); break; } return { bookmarks: await Promise.all( results.map((b) => toZodSchema(b, input.includeContent)), ), nextCursor: resp.hits.length + (input.cursor?.offset || 0) >= resp.totalHits ? null : { ver: 1 as const, offset: resp.hits.length + (input.cursor?.offset || 0), }, }; }), getBookmarks: authedProcedure .input(zGetBookmarksRequestSchema) .output(zGetBookmarksResponseSchema) .query(async ({ input, ctx }) => { const res = await Bookmark.loadMulti(ctx, input); return { bookmarks: res.bookmarks.map((b) => b.asZBookmark()), nextCursor: res.nextCursor, }; }), updateTags: authedProcedure .input( z.object({ bookmarkId: z.string(), attach: z.array(zManipulatedTagSchema), detach: z.array(zManipulatedTagSchema), }), ) .output( z.object({ attached: z.array(z.string()), detached: z.array(z.string()), }), ) .use(ensureBookmarkOwnership) .mutation(async ({ input, ctx }) => { return ctx.db.transaction(async (tx) => { // Detaches const idsToRemove: string[] = []; if (input.detach.length > 0) { const namesToRemove: string[] = []; input.detach.forEach((detachInfo) => { if (detachInfo.tagId) { idsToRemove.push(detachInfo.tagId); } if (detachInfo.tagName) { namesToRemove.push(detachInfo.tagName); } }); if (namesToRemove.length > 0) { ( await tx.query.bookmarkTags.findMany({ where: and( eq(bookmarkTags.userId, ctx.user.id), inArray(bookmarkTags.name, namesToRemove), ), columns: { id: true, }, }) ).forEach((tag) => { idsToRemove.push(tag.id); }); } await tx .delete(tagsOnBookmarks) .where( and( eq(tagsOnBookmarks.bookmarkId, input.bookmarkId), inArray(tagsOnBookmarks.tagId, idsToRemove), ), ); } if (input.attach.length == 0) { return { bookmarkId: input.bookmarkId, attached: [], detached: idsToRemove, }; } const toAddTagNames = input.attach .flatMap((i) => (i.tagName ? [i.tagName] : [])) .map(normalizeTagName) // strip leading # .filter((n) => n.length > 0); // drop empty results const toAddTagIds = input.attach.flatMap((i) => i.tagId ? [i.tagId] : [], ); // New Tags if (toAddTagNames.length > 0) { await tx .insert(bookmarkTags) .values( toAddTagNames.map((name) => ({ name, userId: ctx.user.id })), ) .onConflictDoNothing() .returning(); } // If there is nothing to add, the "or" statement will become useless and // the query below will simply select all the existing tags for this user and assign them to the bookmark invariant(toAddTagNames.length > 0 || toAddTagIds.length > 0); const allIds = ( await tx.query.bookmarkTags.findMany({ where: and( eq(bookmarkTags.userId, ctx.user.id), or( toAddTagIds.length > 0 ? inArray(bookmarkTags.id, toAddTagIds) : undefined, toAddTagNames.length > 0 ? inArray(bookmarkTags.name, toAddTagNames) : undefined, ), ), columns: { id: true, }, }) ).map((t) => t.id); await tx .insert(tagsOnBookmarks) .values( allIds.map((i) => ({ tagId: i, bookmarkId: input.bookmarkId, attachedBy: "human" as const, userId: ctx.user.id, })), ) .onConflictDoNothing(); await tx .update(bookmarks) .set({ modifiedAt: new Date() }) .where( and( eq(bookmarks.id, input.bookmarkId), eq(bookmarks.userId, ctx.user.id), ), ); await triggerRuleEngineOnEvent(input.bookmarkId, [ ...idsToRemove.map((t) => ({ type: "tagRemoved" as const, tagId: t, })), ...allIds.map((t) => ({ type: "tagAdded" as const, tagId: t, })), ]); await triggerSearchReindex(input.bookmarkId); await triggerWebhook(input.bookmarkId, "edited"); return { bookmarkId: input.bookmarkId, attached: allIds, detached: idsToRemove, }; }); }), getBrokenLinks: authedProcedure .output( z.object({ bookmarks: z.array( z.object({ id: z.string(), url: z.string(), statusCode: z.number().nullable(), isCrawlingFailure: z.boolean(), crawledAt: z.date().nullable(), createdAt: z.date().nullable(), }), ), }), ) .query(async ({ ctx }) => { const brokenLinkBookmarks = await ctx.db .select({ id: bookmarkLinks.id, url: bookmarkLinks.url, crawlStatusCode: bookmarkLinks.crawlStatusCode, crawlingStatus: bookmarkLinks.crawlStatus, crawledAt: bookmarkLinks.crawledAt, createdAt: bookmarks.createdAt, }) .from(bookmarkLinks) .leftJoin(bookmarks, eq(bookmarks.id, bookmarkLinks.id)) .where( and( eq(bookmarks.userId, ctx.user.id), or( eq(bookmarkLinks.crawlStatus, "failure"), lt(bookmarkLinks.crawlStatusCode, 200), gt(bookmarkLinks.crawlStatusCode, 299), ), ), ); return { bookmarks: brokenLinkBookmarks.map((b) => ({ id: b.id, url: b.url, statusCode: b.crawlStatusCode, isCrawlingFailure: b.crawlingStatus === "failure", crawledAt: b.crawledAt, createdAt: b.createdAt, })), }; }), summarizeBookmark: authedProcedure .use( createRateLimitMiddleware({ name: "bookmarks.summarizeBookmark", windowMs: 30 * 60 * 1000, maxRequests: 100, }), ) .input( z.object({ bookmarkId: z.string(), }), ) .output( z.object({ summary: z.string(), }), ) .use(ensureBookmarkOwnership) .mutation(async ({ input, ctx }) => { const inferenceClient = InferenceClientFactory.build(); if (!inferenceClient) { throw new TRPCError({ code: "BAD_REQUEST", message: "No inference client configured", }); } const bookmark = await ctx.db.query.bookmarkLinks.findFirst({ where: eq(bookmarkLinks.id, input.bookmarkId), }); if (!bookmark) { throw new TRPCError({ code: "NOT_FOUND", message: "Bookmark not found or not a link", }); } const content = await Bookmark.getBookmarkPlainTextContent( bookmark, ctx.user.id, ); const bookmarkDetails = ` Title: ${bookmark.title ?? ""} Description: ${bookmark.description ?? ""} Content: ${content} Publisher: ${bookmark.publisher ?? ""} Author: ${bookmark.author ?? ""} `; const prompts = await ctx.db.query.customPrompts.findMany({ where: and( eq(customPrompts.userId, ctx.user.id), eq(customPrompts.appliesTo, "summary"), ), columns: { text: true, }, }); const summaryPrompt = buildSummaryPrompt( serverConfig.inference.inferredTagLang, prompts.map((p) => p.text), bookmarkDetails, serverConfig.inference.contextLength, ); const summary = await inferenceClient.inferFromText(summaryPrompt, { schema: null, }); if (!summary.response) { throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Failed to summarize bookmark", }); } await ctx.db .update(bookmarks) .set({ summary: summary.response, }) .where(eq(bookmarks.id, input.bookmarkId)); await triggerSearchReindex(input.bookmarkId); await triggerWebhook(input.bookmarkId, "edited"); return { bookmarkId: input.bookmarkId, summary: summary.response, }; }), });

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/karakeep-app/karakeep'

If you have feedback or need assistance with the MCP directory API, please join our Discord server