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/browser-extension/package.json | 2 +- apps/mcp/.gitignore | 1 + apps/mcp/.npmignore | 4 + apps/mcp/README.md | 34 +++++++++ apps/mcp/package.json | 51 +++++++++++++ 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 +++++++++++++++++ apps/mcp/tsconfig.json | 15 ++++ apps/mcp/vite.config.mts | 26 +++++++ apps/mobile/package.json | 2 +- apps/web/package.json | 2 +- apps/workers/package.json | 2 +- 15 files changed, 535 insertions(+), 4 deletions(-) create mode 100644 apps/mcp/.gitignore create mode 100644 apps/mcp/.npmignore create mode 100644 apps/mcp/README.md create mode 100644 apps/mcp/package.json 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 create mode 100644 apps/mcp/tsconfig.json create mode 100644 apps/mcp/vite.config.mts (limited to 'apps') diff --git a/apps/browser-extension/package.json b/apps/browser-extension/package.json index 58da5217..2f4dd7f7 100644 --- a/apps/browser-extension/package.json +++ b/apps/browser-extension/package.json @@ -37,7 +37,7 @@ "superjson": "^2.2.1", "tailwind-merge": "^2.2.1", "tailwindcss-animate": "^1.0.7", - "zod": "^3.22.4" + "zod": "^3.24.2" }, "devDependencies": { "@crxjs/vite-plugin": "2.0.0-beta.28", diff --git a/apps/mcp/.gitignore b/apps/mcp/.gitignore new file mode 100644 index 00000000..1521c8b7 --- /dev/null +++ b/apps/mcp/.gitignore @@ -0,0 +1 @@ +dist diff --git a/apps/mcp/.npmignore b/apps/mcp/.npmignore new file mode 100644 index 00000000..18a504f5 --- /dev/null +++ b/apps/mcp/.npmignore @@ -0,0 +1,4 @@ +.turbo/** +src/** +vite.config.mts +tsconfig.json diff --git a/apps/mcp/README.md b/apps/mcp/README.md new file mode 100644 index 00000000..d59bb894 --- /dev/null +++ b/apps/mcp/README.md @@ -0,0 +1,34 @@ +# Karakeep MCP Server + +This is the Karakeep MCP server, which is a server that can be used to interact with Karakeep from other tools. + +## Supported Tools + +- Searching bookmarks +- Adding and removing bookmarks from lists +- Attaching and detaching tags to bookmarks +- Creating new lists +- Creating text and URL bookmarks + +Currently, the MCP server only exposes tools (no resources). + +## Usage with Claude Desktop + + +``` +{ + "mcpServers": { + "karakeep": { + "command": "npx", + "args": [ + "@karakeep/mcp", + ], + "env": { + "KARAKEEP_API_ADDR": "https://", + "KARAKEEP_API_KEY": "" + } + } + } +} +``` + diff --git a/apps/mcp/package.json b/apps/mcp/package.json new file mode 100644 index 00000000..5e41dd18 --- /dev/null +++ b/apps/mcp/package.json @@ -0,0 +1,51 @@ +{ + "$schema": "https://json.schemastore.org/package.json", + "name": "@karakeep/mcp", + "version": "0.23.3", + "description": "MCP server for Karakeep", + "license": "GNU Affero General Public License version 3", + "type": "module", + "keywords": [ + "hoarder", + "karakeep", + "mcp" + ], + "bin": { + "karakeep-mcp": "dist/index.js" + }, + "devDependencies": { + "@karakeep/eslint-config": "workspace:^0.2.0", + "@karakeep/prettier-config": "workspace:^0.1.0", + "@karakeep/tsconfig": "workspace:^0.1.0", + "@tsconfig/node22": "^22.0.0", + "shx": "^0.4.0", + "tsx": "^4.7.1", + "vite": "^5.1.0" + }, + "scripts": { + "build": "vite build && shx chmod +x dist/index.js", + "run": "tsx src/index.ts", + "lint": "eslint .", + "lint:fix": "eslint . --fix", + "format": "prettier . --ignore-path ../../.prettierignore", + "format:fix": "prettier . --write --ignore-path ../../.prettierignore", + "typecheck": "tsc --noEmit" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/karakeep-app/karakeep.git", + "directory": "apps/mcp" + }, + "eslintConfig": { + "root": true, + "extends": [ + "@karakeep/eslint-config/base" + ] + }, + "prettier": "@karakeep/prettier-config", + "dependencies": { + "@karakeep/sdk": "workspace:*", + "@modelcontextprotocol/sdk": "^1.9.0", + "zod": "^3.24.2" + } +} 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}`, + }, + ], + }; + }, +); diff --git a/apps/mcp/tsconfig.json b/apps/mcp/tsconfig.json new file mode 100644 index 00000000..07b074a3 --- /dev/null +++ b/apps/mcp/tsconfig.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "@karakeep/tsconfig/node.json", + "include": ["src", "vite.config.mts"], + "exclude": ["node_modules", "dist"], + "compilerOptions": { + "baseUrl": ".", + "tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json", + "strictNullChecks": true, + "paths": { + "@/*": ["./src/*"] + }, + "types": ["vite/client"] + } +} diff --git a/apps/mcp/vite.config.mts b/apps/mcp/vite.config.mts new file mode 100644 index 00000000..81717bff --- /dev/null +++ b/apps/mcp/vite.config.mts @@ -0,0 +1,26 @@ +// This file is shamelessly copied from immich's CLI vite config +// https://github.com/immich-app/immich/blob/main/cli/vite.config.ts +import { defineConfig } from "vite"; +import tsconfigPaths from "vite-tsconfig-paths"; + +export default defineConfig({ + build: { + rollupOptions: { + input: "src/index.ts", + output: { + dir: "dist", + }, + }, + ssr: true, + }, + ssr: { + // bundle everything except for Node built-ins + noExternal: /^(?!node:).*$/, + }, + plugins: [tsconfigPaths()], + define: { + "import.meta.env.CLI_VERSION": JSON.stringify( + process.env.npm_package_version, + ), + }, +}); diff --git a/apps/mobile/package.json b/apps/mobile/package.json index c17d4f26..4ce6a718 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -55,7 +55,7 @@ "react-native-webview": "^13.12.2", "tailwind-merge": "^2.2.1", "use-debounce": "^10.0.0", - "zod": "^3.22.4", + "zod": "^3.24.2", "zustand": "^4.5.1" }, "devDependencies": { diff --git a/apps/web/package.json b/apps/web/package.json index db02bbaa..5279dad8 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -90,7 +90,7 @@ "sharp": "^0.33.3", "superjson": "^2.2.1", "tailwind-merge": "^2.2.1", - "zod": "^3.22.4", + "zod": "^3.24.2", "zustand": "^4.5.1" }, "devDependencies": { diff --git a/apps/workers/package.json b/apps/workers/package.json index 7ce1cbcf..91487973 100644 --- a/apps/workers/package.json +++ b/apps/workers/package.json @@ -43,7 +43,7 @@ "tesseract.js": "^5.1.1", "tsx": "^4.7.1", "typescript": "^5.7.3", - "zod": "^3.22.4" + "zod": "^3.24.2" }, "devDependencies": { "@karakeep/eslint-config": "workspace:^0.2.0", -- cgit v1.2.3-70-g09d2