Skip to main content
Glama

Karakeep MCP server

by karakeep-app
lists.ts13.6 kB
import crypto from "node:crypto"; import { TRPCError } from "@trpc/server"; import { and, count, eq, or } from "drizzle-orm"; import invariant from "tiny-invariant"; import { z } from "zod"; import { SqliteError } from "@karakeep/db"; import { bookmarkLists, bookmarksInLists } from "@karakeep/db/schema"; import { triggerRuleEngineOnEvent } from "@karakeep/shared-server"; import { parseSearchQuery } from "@karakeep/shared/searchQueryParser"; import { ZSortOrder } from "@karakeep/shared/types/bookmarks"; import { ZBookmarkList, zEditBookmarkListSchemaWithValidation, zNewBookmarkListSchema, } from "@karakeep/shared/types/lists"; import { ZCursor } from "@karakeep/shared/types/pagination"; import { AuthedContext, Context } from ".."; import { buildImpersonatingAuthedContext } from "../lib/impersonate"; import { getBookmarkIdsFromMatcher } from "../lib/search"; import { Bookmark } from "./bookmarks"; import { PrivacyAware } from "./privacy"; export abstract class List implements PrivacyAware { protected constructor( protected ctx: AuthedContext, public list: ZBookmarkList & { userId: string }, ) {} private static fromData( ctx: AuthedContext, data: ZBookmarkList & { userId: string }, ) { if (data.type === "smart") { return new SmartList(ctx, data); } else { return new ManualList(ctx, data); } } static async fromId( ctx: AuthedContext, id: string, ): Promise<ManualList | SmartList> { const list = await ctx.db.query.bookmarkLists.findFirst({ where: and( eq(bookmarkLists.id, id), eq(bookmarkLists.userId, ctx.user.id), ), }); if (!list) { throw new TRPCError({ code: "NOT_FOUND", message: "List not found", }); } if (list.type === "smart") { return new SmartList(ctx, list); } else { return new ManualList(ctx, list); } } private static async getPublicList( ctx: Context, listId: string, token: string | null, ) { const listdb = await ctx.db.query.bookmarkLists.findFirst({ where: and( eq(bookmarkLists.id, listId), or( eq(bookmarkLists.public, true), token !== null ? eq(bookmarkLists.rssToken, token) : undefined, ), ), with: { user: { columns: { name: true, }, }, }, }); if (!listdb) { throw new TRPCError({ code: "NOT_FOUND", message: "List not found", }); } return listdb; } static async getPublicListMetadata( ctx: Context, listId: string, token: string | null, ) { const listdb = await this.getPublicList(ctx, listId, token); return { userId: listdb.userId, name: listdb.name, description: listdb.description, icon: listdb.icon, ownerName: listdb.user.name, }; } static async getPublicListContents( ctx: Context, listId: string, token: string | null, pagination: { limit: number; order: Exclude<ZSortOrder, "relevance">; cursor: ZCursor | null | undefined; }, ) { const listdb = await this.getPublicList(ctx, listId, token); // The token here acts as an authed context, so we can create // an impersonating context for the list owner as long as // we don't leak the context. const authedCtx = await buildImpersonatingAuthedContext(listdb.userId); const list = List.fromData(authedCtx, listdb); const bookmarkIds = await list.getBookmarkIds(); const bookmarks = await Bookmark.loadMulti(authedCtx, { ids: bookmarkIds, includeContent: false, limit: pagination.limit, sortOrder: pagination.order, cursor: pagination.cursor, }); return { list: { icon: list.list.icon, name: list.list.name, description: list.list.description, ownerName: listdb.user.name, numItems: bookmarkIds.length, }, bookmarks: bookmarks.bookmarks.map((b) => b.asPublicBookmark()), nextCursor: bookmarks.nextCursor, }; } static async create( ctx: AuthedContext, input: z.infer<typeof zNewBookmarkListSchema>, ): Promise<ManualList | SmartList> { const [result] = await ctx.db .insert(bookmarkLists) .values({ name: input.name, description: input.description, icon: input.icon, userId: ctx.user.id, parentId: input.parentId, type: input.type, query: input.query, }) .returning(); return this.fromData(ctx, result); } static async getAll(ctx: AuthedContext): Promise<(ManualList | SmartList)[]> { const lists = await ctx.db.query.bookmarkLists.findMany({ columns: { rssToken: false, }, where: and(eq(bookmarkLists.userId, ctx.user.id)), }); return lists.map((l) => this.fromData(ctx, l)); } static async forBookmark(ctx: AuthedContext, bookmarkId: string) { const lists = await ctx.db.query.bookmarksInLists.findMany({ where: and(eq(bookmarksInLists.bookmarkId, bookmarkId)), with: { list: { columns: { rssToken: false, }, }, }, }); invariant(lists.map((l) => l.list.userId).every((id) => id == ctx.user.id)); return lists.map((l) => this.fromData(ctx, l.list)); } ensureCanAccess(ctx: AuthedContext): void { if (this.list.userId != ctx.user.id) { throw new TRPCError({ code: "FORBIDDEN", message: "User is not allowed to access resource", }); } } async delete() { const res = await this.ctx.db .delete(bookmarkLists) .where( and( eq(bookmarkLists.id, this.list.id), eq(bookmarkLists.userId, this.ctx.user.id), ), ); if (res.changes == 0) { throw new TRPCError({ code: "NOT_FOUND" }); } } async getChildren(): Promise<(ManualList | SmartList)[]> { const lists = await List.getAll(this.ctx); const listById = new Map(lists.map((l) => [l.list.id, l])); const adjecencyList = new Map<string, string[]>(); // Initialize all lists with empty arrays first lists.forEach((l) => { adjecencyList.set(l.list.id, []); }); // Then populate the parent-child relationships lists.forEach((l) => { if (l.list.parentId) { const currentChildren = adjecencyList.get(l.list.parentId) ?? []; currentChildren.push(l.list.id); adjecencyList.set(l.list.parentId, currentChildren); } }); const resultIds: string[] = []; const queue: string[] = [this.list.id]; while (queue.length > 0) { const id = queue.pop()!; const children = adjecencyList.get(id) ?? []; children.forEach((childId) => { queue.push(childId); resultIds.push(childId); }); } return resultIds.map((id) => listById.get(id)!); } async update( input: z.infer<typeof zEditBookmarkListSchemaWithValidation>, ): Promise<void> { const result = await this.ctx.db .update(bookmarkLists) .set({ name: input.name, description: input.description, icon: input.icon, parentId: input.parentId, query: input.query, public: input.public, }) .where( and( eq(bookmarkLists.id, this.list.id), eq(bookmarkLists.userId, this.ctx.user.id), ), ) .returning(); if (result.length == 0) { throw new TRPCError({ code: "NOT_FOUND" }); } this.list = result[0]; } private async setRssToken(token: string | null) { const result = await this.ctx.db .update(bookmarkLists) .set({ rssToken: token }) .where( and( eq(bookmarkLists.id, this.list.id), eq(bookmarkLists.userId, this.ctx.user.id), ), ) .returning(); if (result.length == 0) { throw new TRPCError({ code: "NOT_FOUND" }); } return result[0].rssToken; } async getRssToken(): Promise<string | null> { const [result] = await this.ctx.db .select({ rssToken: bookmarkLists.rssToken }) .from(bookmarkLists) .where( and( eq(bookmarkLists.id, this.list.id), eq(bookmarkLists.userId, this.ctx.user.id), ), ) .limit(1); return result.rssToken ?? null; } async regenRssToken() { return await this.setRssToken(crypto.randomBytes(32).toString("hex")); } async clearRssToken() { await this.setRssToken(null); } abstract get type(): "manual" | "smart"; abstract getBookmarkIds(ctx: AuthedContext): Promise<string[]>; abstract getSize(ctx: AuthedContext): Promise<number>; abstract addBookmark(bookmarkId: string): Promise<void>; abstract removeBookmark(bookmarkId: string): Promise<void>; abstract mergeInto( targetList: List, deleteSourceAfterMerge: boolean, ): Promise<void>; } export class SmartList extends List { parsedQuery: ReturnType<typeof parseSearchQuery> | null = null; constructor(ctx: AuthedContext, list: ZBookmarkList & { userId: string }) { super(ctx, list); } get type(): "smart" { invariant(this.list.type === "smart"); return this.list.type; } get query() { invariant(this.list.query); return this.list.query; } getParsedQuery() { if (!this.parsedQuery) { const result = parseSearchQuery(this.query); if (result.result !== "full") { throw new Error("Invalid smart list query"); } this.parsedQuery = result; } return this.parsedQuery; } async getBookmarkIds(): Promise<string[]> { const parsedQuery = this.getParsedQuery(); if (!parsedQuery.matcher) { return []; } return await getBookmarkIdsFromMatcher(this.ctx, parsedQuery.matcher); } async getSize(): Promise<number> { return await this.getBookmarkIds().then((ids) => ids.length); } addBookmark(_bookmarkId: string): Promise<void> { throw new TRPCError({ code: "BAD_REQUEST", message: "Smart lists cannot be added to", }); } removeBookmark(_bookmarkId: string): Promise<void> { throw new TRPCError({ code: "BAD_REQUEST", message: "Smart lists cannot be removed from", }); } mergeInto( _targetList: List, _deleteSourceAfterMerge: boolean, ): Promise<void> { throw new TRPCError({ code: "BAD_REQUEST", message: "Smart lists cannot be merged", }); } } export class ManualList extends List { constructor(ctx: AuthedContext, list: ZBookmarkList & { userId: string }) { super(ctx, list); } get type(): "manual" { invariant(this.list.type === "manual"); return this.list.type; } async getBookmarkIds(): Promise<string[]> { const results = await this.ctx.db .select({ id: bookmarksInLists.bookmarkId }) .from(bookmarksInLists) .where(eq(bookmarksInLists.listId, this.list.id)); return results.map((r) => r.id); } async getSize(): Promise<number> { const results = await this.ctx.db .select({ count: count() }) .from(bookmarksInLists) .where(eq(bookmarksInLists.listId, this.list.id)); return results[0].count; } async addBookmark(bookmarkId: string): Promise<void> { try { await this.ctx.db.insert(bookmarksInLists).values({ listId: this.list.id, bookmarkId, }); await triggerRuleEngineOnEvent(bookmarkId, [ { type: "addedToList", listId: this.list.id, }, ]); } catch (e) { if (e instanceof SqliteError) { if (e.code == "SQLITE_CONSTRAINT_PRIMARYKEY") { // this is fine, it just means the bookmark is already in the list return; } } throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Something went wrong", }); } } async removeBookmark(bookmarkId: string): Promise<void> { const deleted = await this.ctx.db .delete(bookmarksInLists) .where( and( eq(bookmarksInLists.listId, this.list.id), eq(bookmarksInLists.bookmarkId, bookmarkId), ), ); if (deleted.changes == 0) { throw new TRPCError({ code: "BAD_REQUEST", message: `Bookmark ${bookmarkId} is already not in list ${this.list.id}`, }); } await triggerRuleEngineOnEvent(bookmarkId, [ { type: "removedFromList", listId: this.list.id, }, ]); } async update(input: z.infer<typeof zEditBookmarkListSchemaWithValidation>) { if (input.query) { throw new TRPCError({ code: "BAD_REQUEST", message: "Manual lists cannot have a query", }); } return super.update(input); } async mergeInto( targetList: List, deleteSourceAfterMerge: boolean, ): Promise<void> { if (targetList.type !== "manual") { throw new TRPCError({ code: "BAD_REQUEST", message: "You can only merge into a manual list", }); } const bookmarkIds = await this.getBookmarkIds(); await this.ctx.db.transaction(async (tx) => { await tx .insert(bookmarksInLists) .values( bookmarkIds.map((id) => ({ bookmarkId: id, listId: targetList.list.id, })), ) .onConflictDoNothing(); if (deleteSourceAfterMerge) { await tx .delete(bookmarkLists) .where(eq(bookmarkLists.id, this.list.id)); } }); } }

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