From cf97bace33fdd14f29ce947d55d17cba8fa85c11 Mon Sep 17 00:00:00 2001 From: Mohamed Bassem Date: Sun, 13 Apr 2025 01:27:45 +0000 Subject: feat: Add an MCP server for karakeep --- apps/mcp/src/bookmarks.ts | 137 +++++++++++++++++++++++++++++++++++++++++++ apps/mcp/src/index.ts | 16 +++++ apps/mcp/src/lists.ts | 145 ++++++++++++++++++++++++++++++++++++++++++++++ apps/mcp/src/shared.ts | 34 +++++++++++ apps/mcp/src/tags.ts | 68 ++++++++++++++++++++++ 5 files changed, 400 insertions(+) create mode 100644 apps/mcp/src/bookmarks.ts create mode 100644 apps/mcp/src/index.ts create mode 100644 apps/mcp/src/lists.ts create mode 100644 apps/mcp/src/shared.ts create mode 100644 apps/mcp/src/tags.ts (limited to 'apps/mcp/src') diff --git a/apps/mcp/src/bookmarks.ts b/apps/mcp/src/bookmarks.ts new file mode 100644 index 00000000..bcafba91 --- /dev/null +++ b/apps/mcp/src/bookmarks.ts @@ -0,0 +1,137 @@ +import { CallToolResult } from "@modelcontextprotocol/sdk/types"; +import { z } from "zod"; + +import { karakeepClient, mcpServer, toMcpToolError } from "./shared"; + +// Tools +mcpServer.tool( + "search-bookmarks", + `Search for bookmarks matching a specific a query. +`, + { + query: z.string().describe(` + By default, this will do a full-text search, but you can also use qualifiers to filter the results. +You can search bookmarks using specific qualifiers. is:fav finds favorited bookmarks, +is:archived searches archived bookmarks, is:tagged finds those with tags, +is:inlist finds those in lists, and is:link, is:text, and is:media filter by bookmark type. +url: searches for URL substrings, # searches for bookmarks with a specific tag, +list: searches for bookmarks in a specific list, +after: finds bookmarks created on or after a date (YYYY-MM-DD), and before: finds bookmarks created on or before a date (YYYY-MM-DD). +If you need to pass names with spaces, you can quote them with double quotes. If you want to negate a qualifier, prefix it with a minus sign. +## Examples: + +### Find favorited bookmarks from 2023 that are tagged "important" +is:fav after:2023-01-01 before:2023-12-31 #important + +### Find archived bookmarks that are either in "reading" list or tagged "work" +is:archived and (list:reading or #work) + +### Combine text search with qualifiers +machine learning is:fav`), + }, + async ({ query }): Promise => { + const res = await karakeepClient.GET("/bookmarks/search", { + params: { + query: { + q: query, + limit: 10, + }, + }, + }); + if (!res.data) { + return toMcpToolError(res.error); + } + return { + content: res.data.bookmarks.map((bookmark) => ({ + type: "text", + text: JSON.stringify(bookmark), + })), + }; + }, +); + +mcpServer.tool( + "get-bookmark", + `Get a bookmark by id.`, + { + bookmarkId: z.string().describe(`The bookmarkId to get.`), + }, + async ({ bookmarkId }): Promise => { + const res = await karakeepClient.GET(`/bookmarks/{bookmarkId}`, { + params: { + path: { + bookmarkId, + }, + }, + }); + if (res.error) { + return toMcpToolError(res.error); + } + return { + content: [ + { + type: "text", + text: JSON.stringify(res.data), + }, + ], + }; + }, +); + +mcpServer.tool( + "create-text-bookmark", + `Create a text bookmark`, + { + title: z.string().optional().describe(`The title of the bookmark`), + text: z.string().describe(`The text to be bookmarked`), + }, + async ({ title, text }): Promise => { + const res = await karakeepClient.POST(`/bookmarks`, { + body: { + type: "text", + title, + text, + }, + }); + if (res.error) { + return toMcpToolError(res.error); + } + return { + content: [ + { + type: "text", + text: JSON.stringify(res.data), + }, + ], + }; + }, +); + +mcpServer.tool( + "create-url-bookmark", + `Create a url bookmark`, + { + title: z.string().optional().describe(`The title of the bookmark`), + url: z.string().describe(`The url to be bookmarked`), + }, + async ({ title, url }): Promise => { + const res = await karakeepClient.POST(`/bookmarks`, { + body: { + type: "link", + title, + url, + }, + }); + if (res.error) { + return toMcpToolError(res.error); + } + return { + content: [ + { + type: "text", + text: JSON.stringify(res.data), + }, + ], + }; + }, +); diff --git a/apps/mcp/src/index.ts b/apps/mcp/src/index.ts new file mode 100644 index 00000000..51437fc6 --- /dev/null +++ b/apps/mcp/src/index.ts @@ -0,0 +1,16 @@ +#!/usr/bin/env node +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; + +import { mcpServer } from "./shared"; + +import "./bookmarks.ts"; +import "./lists.ts"; +import "./tags.ts"; + +async function run() { + // Start receiving messages on stdin and sending messages on stdout + const transport = new StdioServerTransport(); + await mcpServer.connect(transport); +} + +run(); diff --git a/apps/mcp/src/lists.ts b/apps/mcp/src/lists.ts new file mode 100644 index 00000000..6cfe1d13 --- /dev/null +++ b/apps/mcp/src/lists.ts @@ -0,0 +1,145 @@ +import { CallToolResult } from "@modelcontextprotocol/sdk/types"; +import { z } from "zod"; + +import { karakeepClient, mcpServer, toMcpToolError } from "./shared"; + +mcpServer.tool( + "get-lists", + `Search for bookmarks matching a specific a query.`, + async (): Promise => { + const res = await karakeepClient.GET("/lists"); + if (!res.data) { + return toMcpToolError(res.error); + } + return { + content: res.data.lists.map((list) => ({ + type: "text", + text: JSON.stringify(list), + })), + }; + }, +); + +mcpServer.tool( + "get-bookmarks-in-list", + `Search for bookmarks matching a specific a query.`, + { + listId: z.string().describe(`The listId to search in.`), + }, + async ({ listId }): Promise => { + const res = await karakeepClient.GET(`/lists/{listId}/bookmarks`, { + params: { + path: { + listId, + }, + }, + }); + if (res.error) { + return toMcpToolError(res.error); + } + return { + content: res.data.bookmarks.map((bookmark) => ({ + type: "text", + text: JSON.stringify(bookmark), + })), + }; + }, +); + +mcpServer.tool( + "add-bookmark-to-list", + `Add a bookmark to a list.`, + { + listId: z.string().describe(`The listId to add the bookmark to.`), + bookmarkId: z.string().describe(`The bookmarkId to add.`), + }, + async ({ listId, bookmarkId }): Promise => { + const res = await karakeepClient.PUT( + `/lists/{listId}/bookmarks/{bookmarkId}`, + { + params: { + path: { + listId, + bookmarkId, + }, + }, + }, + ); + if (res.error) { + return toMcpToolError(res.error); + } + return { + content: [ + { + type: "text", + text: `Bookmark ${bookmarkId} added to list ${listId}`, + }, + ], + }; + }, +); + +mcpServer.tool( + "remove-bookmark-from-list", + `Remove a bookmark from a list.`, + { + listId: z.string().describe(`The listId to remove the bookmark from.`), + bookmarkId: z.string().describe(`The bookmarkId to remove.`), + }, + async ({ listId, bookmarkId }): Promise => { + const res = await karakeepClient.DELETE( + `/lists/{listId}/bookmarks/{bookmarkId}`, + { + params: { + path: { + listId, + bookmarkId, + }, + }, + }, + ); + if (res.error) { + return toMcpToolError(res.error); + } + return { + content: [ + { + type: "text", + text: `Bookmark ${bookmarkId} removed from list ${listId}`, + }, + ], + }; + }, +); + +mcpServer.tool( + "create-list", + `Create a list.`, + { + name: z.string().describe(`The name of the list.`), + icon: z.string().describe(`The emoji icon of the list.`), + parentId: z + .string() + .optional() + .describe(`The parent list id of this list.`), + }, + async ({ name, icon }): Promise => { + const res = await karakeepClient.POST("/lists", { + body: { + name, + icon, + }, + }); + if (res.error) { + return toMcpToolError(res.error); + } + return { + content: [ + { + type: "text", + text: `List ${name} created with id ${res.data.id}`, + }, + ], + }; + }, +); diff --git a/apps/mcp/src/shared.ts b/apps/mcp/src/shared.ts new file mode 100644 index 00000000..69672769 --- /dev/null +++ b/apps/mcp/src/shared.ts @@ -0,0 +1,34 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { CallToolResult } from "@modelcontextprotocol/sdk/types"; + +import { createHoarderClient } from "@karakeep/sdk"; + +const addr = process.env.KARAKEEP_API_ADDR; +const apiKey = process.env.KARAKEEP_API_KEY; + +export const karakeepClient = createHoarderClient({ + baseUrl: `${addr}/api/v1`, + headers: { + "Content-Type": "application/json", + authorization: `Bearer ${apiKey}`, + }, +}); + +export const mcpServer = new McpServer({ + name: "Karakeep", + version: "0.23.0", +}); + +export function toMcpToolError( + error: { code: string; message: string } | undefined, +): CallToolResult { + return { + isError: true, + content: [ + { + type: "text", + text: error ? JSON.stringify(error) : `Something went wrong`, + }, + ], + }; +} diff --git a/apps/mcp/src/tags.ts b/apps/mcp/src/tags.ts new file mode 100644 index 00000000..a23abea0 --- /dev/null +++ b/apps/mcp/src/tags.ts @@ -0,0 +1,68 @@ +import { CallToolResult } from "@modelcontextprotocol/sdk/types"; +import { z } from "zod"; + +import { karakeepClient, mcpServer, toMcpToolError } from "./shared"; + +mcpServer.tool( + "attach-tag-to-bookmark", + `Attach a tag to a bookmark.`, + { + bookmarkId: z.string().describe(`The bookmarkId to attach the tag to.`), + tagsToAttach: z.array(z.string()).describe(`The tag names to attach.`), + }, + async ({ bookmarkId, tagsToAttach }): Promise => { + const res = await karakeepClient.POST(`/bookmarks/{bookmarkId}/tags`, { + params: { + path: { + bookmarkId, + }, + }, + body: { + tags: tagsToAttach.map((tag) => ({ tagName: tag })), + }, + }); + if (res.error) { + return toMcpToolError(res.error); + } + return { + content: [ + { + type: "text", + text: `Tags ${JSON.stringify(tagsToAttach)} attached to bookmark ${bookmarkId}`, + }, + ], + }; + }, +); + +mcpServer.tool( + "detach-tag-from-bookmark", + `Detach a tag from a bookmark.`, + { + bookmarkId: z.string().describe(`The bookmarkId to detach the tag from.`), + tagsToDetach: z.array(z.string()).describe(`The tag names to detach.`), + }, + async ({ bookmarkId, tagsToDetach }): Promise => { + const res = await karakeepClient.DELETE(`/bookmarks/{bookmarkId}/tags`, { + params: { + path: { + bookmarkId, + }, + }, + body: { + tags: tagsToDetach.map((tag) => ({ tagName: tag })), + }, + }); + if (res.error) { + return toMcpToolError(res.error); + } + return { + content: [ + { + type: "text", + text: `Tags ${JSON.stringify(tagsToDetach)} detached from bookmark ${bookmarkId}`, + }, + ], + }; + }, +); -- cgit v1.2.3-70-g09d2