aboutsummaryrefslogtreecommitdiffstats
path: root/apps/mcp/src
diff options
context:
space:
mode:
authorMohamed Bassem <me@mbassem.com>2025-04-13 01:27:45 +0000
committerMohamed Bassem <me@mbassem.com>2025-04-13 01:53:11 +0000
commitcf97bace33fdd14f29ce947d55d17cba8fa85c11 (patch)
tree048a88eeabfcb1b1b32b2cd298c229e6c7082341 /apps/mcp/src
parenta39cd5f3c0a3e287652f945d203facab413b5b65 (diff)
downloadkarakeep-cf97bace33fdd14f29ce947d55d17cba8fa85c11.tar.zst
feat: Add an MCP server for karakeep
Diffstat (limited to 'apps/mcp/src')
-rw-r--r--apps/mcp/src/bookmarks.ts137
-rw-r--r--apps/mcp/src/index.ts16
-rw-r--r--apps/mcp/src/lists.ts145
-rw-r--r--apps/mcp/src/shared.ts34
-rw-r--r--apps/mcp/src/tags.ts68
5 files changed, 400 insertions, 0 deletions
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:<value> searches for URL substrings, #<tag> searches for bookmarks with a specific tag,
+list:<name> searches for bookmarks in a specific list,
+after:<date> finds bookmarks created on or after a date (YYYY-MM-DD), and before:<date> 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<CallToolResult> => {
+ 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<CallToolResult> => {
+ 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<CallToolResult> => {
+ 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<CallToolResult> => {
+ 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<CallToolResult> => {
+ 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<CallToolResult> => {
+ 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<CallToolResult> => {
+ 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<CallToolResult> => {
+ 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<CallToolResult> => {
+ 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<CallToolResult> => {
+ 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<CallToolResult> => {
+ 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}`,
+ },
+ ],
+ };
+ },
+);