linkagogo-mcp 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +114 -0
- package/dist/client.d.ts +8 -0
- package/dist/client.js +48 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +39 -0
- package/dist/resources/folders.d.ts +5 -0
- package/dist/resources/folders.js +36 -0
- package/dist/resources/recent.d.ts +5 -0
- package/dist/resources/recent.js +18 -0
- package/dist/tools/account.d.ts +5 -0
- package/dist/tools/account.js +61 -0
- package/dist/tools/bookmarks.d.ts +5 -0
- package/dist/tools/bookmarks.js +110 -0
- package/dist/tools/bulk.d.ts +5 -0
- package/dist/tools/bulk.js +39 -0
- package/dist/tools/folders.d.ts +5 -0
- package/dist/tools/folders.js +111 -0
- package/dist/tools/importexport.d.ts +5 -0
- package/dist/tools/importexport.js +40 -0
- package/package.json +36 -0
package/README.md
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# LinkaGoGo MCP Server
|
|
2
|
+
|
|
3
|
+
Manage your LinkaGoGo bookmarks with Claude using the [Model Context Protocol](https://modelcontextprotocol.io/). Search, add, organize, tag, and export bookmarks conversationally — no need to open the web UI.
|
|
4
|
+
|
|
5
|
+
## What You Need
|
|
6
|
+
|
|
7
|
+
1. **A LinkaGoGo account** with a Plus or Premium subscription — [sign up](https://www.linkagogo.com/app/?signup)
|
|
8
|
+
2. **An API key** — generate one in your LinkaGoGo account under Account > API Keys
|
|
9
|
+
3. **Node.js 18 or later** — [download](https://nodejs.org/)
|
|
10
|
+
4. **Claude Desktop** — [download](https://claude.ai/download)
|
|
11
|
+
|
|
12
|
+
## Step 1: Get Your API Key
|
|
13
|
+
|
|
14
|
+
1. Log in to [LinkaGoGo](https://www.linkagogo.com/app/)
|
|
15
|
+
2. Click your avatar in the top-right corner and select **Account**
|
|
16
|
+
3. Scroll down to the **API Keys** section
|
|
17
|
+
4. Enter a name (e.g. "Claude Desktop") and click **Generate Key**
|
|
18
|
+
5. Copy the key immediately — it starts with `lgg_` and won't be shown again
|
|
19
|
+
|
|
20
|
+
## Step 2: Configure Claude Desktop
|
|
21
|
+
|
|
22
|
+
Open the Claude Desktop configuration file:
|
|
23
|
+
|
|
24
|
+
- **macOS**: `~/Library/Application Support/Claude/claude_desktop_config.json`
|
|
25
|
+
- **Windows**: `%APPDATA%\Claude\claude_desktop_config.json`
|
|
26
|
+
|
|
27
|
+
If the file doesn't exist, create it. Add the LinkaGoGo MCP server:
|
|
28
|
+
|
|
29
|
+
```json
|
|
30
|
+
{
|
|
31
|
+
"mcpServers": {
|
|
32
|
+
"linkagogo": {
|
|
33
|
+
"command": "npx",
|
|
34
|
+
"args": ["-y", "linkagogo-mcp"],
|
|
35
|
+
"env": {
|
|
36
|
+
"LINKAGOGO_API_TOKEN": "lgg_your_api_key_here"
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Replace `lgg_your_api_key_here` with your API key from Step 1. No download or build step needed — Claude Desktop installs the server automatically via `npx`.
|
|
44
|
+
|
|
45
|
+
## Step 3: Restart Claude Desktop
|
|
46
|
+
|
|
47
|
+
Quit Claude Desktop completely (Cmd+Q on macOS, not just close the window), then reopen it.
|
|
48
|
+
|
|
49
|
+
You should see the hammer icon in the chat input area. Click it to verify LinkaGoGo's tools are listed.
|
|
50
|
+
|
|
51
|
+
## Try It Out
|
|
52
|
+
|
|
53
|
+
Ask Claude naturally:
|
|
54
|
+
|
|
55
|
+
- "How many bookmarks do I have?"
|
|
56
|
+
- "Search my bookmarks for Python tutorials"
|
|
57
|
+
- "Add https://example.com to my Dev folder with tags 'reference' and 'docs'"
|
|
58
|
+
- "What are my most visited bookmarks?"
|
|
59
|
+
- "Show me my folder tree"
|
|
60
|
+
- "Move all bookmarks tagged 'old' into an Archive folder"
|
|
61
|
+
- "Export my Recipes folder as XBEL"
|
|
62
|
+
- "What reminders are due?"
|
|
63
|
+
|
|
64
|
+
## Available Tools
|
|
65
|
+
|
|
66
|
+
| Tool | What It Does |
|
|
67
|
+
|------|-------------|
|
|
68
|
+
| `search_bookmarks` | Search by keyword, filter by folder, sort by date/visits/rating |
|
|
69
|
+
| `add_bookmark` | Save a new bookmark (auto-fetches title if omitted) |
|
|
70
|
+
| `update_bookmark` | Update title, URL, keywords, rating, favorite, reminder |
|
|
71
|
+
| `delete_bookmark` | Delete a bookmark |
|
|
72
|
+
| `move_bookmarks` | Move bookmarks to a folder (bulk) |
|
|
73
|
+
| `tag_bookmarks` | Add or remove keyword tags (bulk) |
|
|
74
|
+
| `list_folders` | Show folder tree or children of a folder |
|
|
75
|
+
| `create_folder` | Create a folder or a nested path (e.g. "Dev/Python/Libs") |
|
|
76
|
+
| `rename_folder` | Rename a folder |
|
|
77
|
+
| `move_folder` | Move a folder to a new parent |
|
|
78
|
+
| `delete_folder` | Delete a folder and its contents |
|
|
79
|
+
| `export_bookmarks` | Export as XBEL or Netscape HTML (full or by folder) |
|
|
80
|
+
| `import_bookmarks` | Import from XBEL or Netscape HTML |
|
|
81
|
+
| `get_stats` | Account statistics |
|
|
82
|
+
| `get_reminders` | Bookmarks with due reminders |
|
|
83
|
+
|
|
84
|
+
## Troubleshooting
|
|
85
|
+
|
|
86
|
+
**"LINKAGOGO_API_TOKEN environment variable is required"**
|
|
87
|
+
Your API key is missing from the Claude Desktop config. Check `claude_desktop_config.json`.
|
|
88
|
+
|
|
89
|
+
**Tools not showing up in Claude Desktop**
|
|
90
|
+
Make sure you quit and reopened Claude Desktop (not just closed the window). Check that `node` is in your PATH and the path to `dist/index.js` is correct.
|
|
91
|
+
|
|
92
|
+
**"Invalid API key" errors**
|
|
93
|
+
Your API key may have been revoked or mistyped. Generate a new one in Account > API Keys.
|
|
94
|
+
|
|
95
|
+
**"Rate limit exceeded"**
|
|
96
|
+
The API allows 5,000 requests/day on Plus and 10,000/day on Premium per API key. Wait until the next day or generate an additional key.
|
|
97
|
+
|
|
98
|
+
## Updating
|
|
99
|
+
|
|
100
|
+
The MCP server updates automatically — `npx` fetches the latest version each time Claude Desktop starts.
|
|
101
|
+
|
|
102
|
+
## Development
|
|
103
|
+
|
|
104
|
+
To run without building (uses tsx):
|
|
105
|
+
|
|
106
|
+
```bash
|
|
107
|
+
LINKAGOGO_API_TOKEN=lgg_your_key npm run dev
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
To test with the MCP Inspector:
|
|
111
|
+
|
|
112
|
+
```bash
|
|
113
|
+
LINKAGOGO_API_TOKEN=lgg_your_key LINKAGOGO_API_URL=http://localhost:8000 npx @modelcontextprotocol/inspector node dist/index.js
|
|
114
|
+
```
|
package/dist/client.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LinkaGoGo API client — thin fetch wrapper with Bearer auth.
|
|
3
|
+
*/
|
|
4
|
+
export declare class ApiError extends Error {
|
|
5
|
+
status: number;
|
|
6
|
+
constructor(status: number, message: string);
|
|
7
|
+
}
|
|
8
|
+
export declare function api<T = unknown>(method: string, path: string, body?: unknown, contentType?: string): Promise<T>;
|
package/dist/client.js
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LinkaGoGo API client — thin fetch wrapper with Bearer auth.
|
|
3
|
+
*/
|
|
4
|
+
const API_URL = (process.env.LINKAGOGO_API_URL || "https://www.linkagogo.com").replace(/\/$/, "");
|
|
5
|
+
const API_TOKEN = process.env.LINKAGOGO_API_TOKEN || "";
|
|
6
|
+
export class ApiError extends Error {
|
|
7
|
+
status;
|
|
8
|
+
constructor(status, message) {
|
|
9
|
+
super(message);
|
|
10
|
+
this.status = status;
|
|
11
|
+
this.name = "ApiError";
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
export async function api(method, path, body, contentType) {
|
|
15
|
+
const url = `${API_URL}/api/v1${path}`;
|
|
16
|
+
const headers = {
|
|
17
|
+
Authorization: `Bearer ${API_TOKEN}`,
|
|
18
|
+
};
|
|
19
|
+
let reqBody;
|
|
20
|
+
if (body !== undefined) {
|
|
21
|
+
if (contentType) {
|
|
22
|
+
headers["Content-Type"] = contentType;
|
|
23
|
+
reqBody = typeof body === "string" ? body : JSON.stringify(body);
|
|
24
|
+
}
|
|
25
|
+
else {
|
|
26
|
+
headers["Content-Type"] = "application/json";
|
|
27
|
+
reqBody = JSON.stringify(body);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
const resp = await fetch(url, { method, headers, body: reqBody });
|
|
31
|
+
if (resp.status === 204)
|
|
32
|
+
return undefined;
|
|
33
|
+
const ct = resp.headers.get("content-type") || "";
|
|
34
|
+
// Handle non-JSON responses (XBEL export, etc.)
|
|
35
|
+
if (!ct.includes("application/json")) {
|
|
36
|
+
if (!resp.ok) {
|
|
37
|
+
throw new ApiError(resp.status, `API error (${resp.status})`);
|
|
38
|
+
}
|
|
39
|
+
return (await resp.text());
|
|
40
|
+
}
|
|
41
|
+
const data = await resp.json();
|
|
42
|
+
if (!resp.ok) {
|
|
43
|
+
const detail = data?.detail;
|
|
44
|
+
const message = typeof detail === "string" ? detail : detail?.message || `API error (${resp.status})`;
|
|
45
|
+
throw new ApiError(resp.status, message);
|
|
46
|
+
}
|
|
47
|
+
return data;
|
|
48
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* LinkaGoGo MCP Server — expose bookmark management to AI assistants.
|
|
4
|
+
*
|
|
5
|
+
* Environment variables:
|
|
6
|
+
* LINKAGOGO_API_TOKEN — Required. API key (lgg_...) from Account > API Keys.
|
|
7
|
+
* LINKAGOGO_API_URL — Optional. Base URL (default: https://www.linkagogo.com).
|
|
8
|
+
*/
|
|
9
|
+
export {};
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* LinkaGoGo MCP Server — expose bookmark management to AI assistants.
|
|
4
|
+
*
|
|
5
|
+
* Environment variables:
|
|
6
|
+
* LINKAGOGO_API_TOKEN — Required. API key (lgg_...) from Account > API Keys.
|
|
7
|
+
* LINKAGOGO_API_URL — Optional. Base URL (default: https://www.linkagogo.com).
|
|
8
|
+
*/
|
|
9
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
10
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
11
|
+
import { registerBookmarkTools } from "./tools/bookmarks.js";
|
|
12
|
+
import { registerBulkTools } from "./tools/bulk.js";
|
|
13
|
+
import { registerFolderTools } from "./tools/folders.js";
|
|
14
|
+
import { registerImportExportTools } from "./tools/importexport.js";
|
|
15
|
+
import { registerAccountTools } from "./tools/account.js";
|
|
16
|
+
import { registerFolderResource } from "./resources/folders.js";
|
|
17
|
+
import { registerRecentResource } from "./resources/recent.js";
|
|
18
|
+
// Validate required env var
|
|
19
|
+
if (!process.env.LINKAGOGO_API_TOKEN) {
|
|
20
|
+
console.error("Error: LINKAGOGO_API_TOKEN environment variable is required.");
|
|
21
|
+
console.error("Generate an API key at https://www.linkagogo.com/app (Account > API Keys).");
|
|
22
|
+
process.exit(1);
|
|
23
|
+
}
|
|
24
|
+
const server = new McpServer({
|
|
25
|
+
name: "linkagogo",
|
|
26
|
+
version: "0.1.0",
|
|
27
|
+
});
|
|
28
|
+
// Register tools
|
|
29
|
+
registerBookmarkTools(server);
|
|
30
|
+
registerBulkTools(server);
|
|
31
|
+
registerFolderTools(server);
|
|
32
|
+
registerImportExportTools(server);
|
|
33
|
+
registerAccountTools(server);
|
|
34
|
+
// Register resources
|
|
35
|
+
registerFolderResource(server);
|
|
36
|
+
registerRecentResource(server);
|
|
37
|
+
// Start stdio transport
|
|
38
|
+
const transport = new StdioServerTransport();
|
|
39
|
+
await server.connect(transport);
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Folder tree resource — bookmarks://folders
|
|
3
|
+
*/
|
|
4
|
+
import { api } from "../client.js";
|
|
5
|
+
function buildTree(folders) {
|
|
6
|
+
const childrenMap = new Map();
|
|
7
|
+
for (const f of folders) {
|
|
8
|
+
const parent = f.folder_id || 0;
|
|
9
|
+
if (!childrenMap.has(parent))
|
|
10
|
+
childrenMap.set(parent, []);
|
|
11
|
+
childrenMap.get(parent).push(f);
|
|
12
|
+
}
|
|
13
|
+
const lines = [];
|
|
14
|
+
function walk(parentId, indent) {
|
|
15
|
+
const children = childrenMap.get(parentId) || [];
|
|
16
|
+
for (const f of children) {
|
|
17
|
+
const pub = f.is_public ? " (public)" : "";
|
|
18
|
+
lines.push(`${indent}[${f.id}] ${f.name}${pub}`);
|
|
19
|
+
walk(f.id, indent + " ");
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
walk(0, "");
|
|
23
|
+
return lines.join("\n") || "(no folders)";
|
|
24
|
+
}
|
|
25
|
+
export function registerFolderResource(server) {
|
|
26
|
+
server.resource("folder-tree", "bookmarks://folders", async (uri) => {
|
|
27
|
+
const folders = await api("GET", "/folders");
|
|
28
|
+
return {
|
|
29
|
+
contents: [{
|
|
30
|
+
uri: uri.href,
|
|
31
|
+
mimeType: "text/plain",
|
|
32
|
+
text: `Folder Tree:\n${buildTree(folders)}`,
|
|
33
|
+
}],
|
|
34
|
+
};
|
|
35
|
+
});
|
|
36
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Recent bookmarks resource — bookmarks://recent
|
|
3
|
+
*/
|
|
4
|
+
import { api } from "../client.js";
|
|
5
|
+
export function registerRecentResource(server) {
|
|
6
|
+
server.resource("recent-bookmarks", "bookmarks://recent", async (uri) => {
|
|
7
|
+
const data = await api("GET", "/urls?sort=date_desc&page_size=25");
|
|
8
|
+
const lines = data.bookmarks.map((bm) => `[${bm.id}] ${bm.title || "(untitled)"}\n ${bm.url}\n Added: ${bm.added_date}${bm.folder_id ? ` | Folder: ${bm.folder_id}` : ""}`);
|
|
9
|
+
lines.push(`\n--- ${data.total} total bookmarks ---`);
|
|
10
|
+
return {
|
|
11
|
+
contents: [{
|
|
12
|
+
uri: uri.href,
|
|
13
|
+
mimeType: "text/plain",
|
|
14
|
+
text: `Recent Bookmarks (25 most recent):\n\n${lines.join("\n\n")}`,
|
|
15
|
+
}],
|
|
16
|
+
};
|
|
17
|
+
});
|
|
18
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Account tools — stats, reminders.
|
|
3
|
+
*/
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { api } from "../client.js";
|
|
6
|
+
const REMINDER_LABELS = {
|
|
7
|
+
0: "Daily",
|
|
8
|
+
[-1]: "Monday", [-2]: "Tuesday", [-3]: "Wednesday", [-4]: "Thursday",
|
|
9
|
+
[-5]: "Friday", [-6]: "Saturday", [-7]: "Sunday",
|
|
10
|
+
};
|
|
11
|
+
function reminderLabel(r) {
|
|
12
|
+
if (REMINDER_LABELS[r])
|
|
13
|
+
return REMINDER_LABELS[r];
|
|
14
|
+
if (r >= -41 && r <= -11)
|
|
15
|
+
return `${-(r + 10)}th of each month`;
|
|
16
|
+
if (r > 0)
|
|
17
|
+
return `Every ${r} days`;
|
|
18
|
+
return "Unknown";
|
|
19
|
+
}
|
|
20
|
+
export function registerAccountTools(server) {
|
|
21
|
+
server.tool("get_stats", "Get account statistics: bookmark count, folder count, favorites, tags, and date range.", {}, async () => {
|
|
22
|
+
try {
|
|
23
|
+
const stats = await api("GET", "/account/stats");
|
|
24
|
+
const lines = [
|
|
25
|
+
`Bookmarks: ${stats.bookmark_count}`,
|
|
26
|
+
`Folders: ${stats.folder_count}`,
|
|
27
|
+
`Favorites: ${stats.favorite_count}`,
|
|
28
|
+
`Unique tags: ${stats.tag_count}`,
|
|
29
|
+
];
|
|
30
|
+
if (stats.oldest_bookmark)
|
|
31
|
+
lines.push(`Oldest: ${stats.oldest_bookmark}`);
|
|
32
|
+
if (stats.newest_bookmark)
|
|
33
|
+
lines.push(`Newest: ${stats.newest_bookmark}`);
|
|
34
|
+
return { content: [{ type: "text", text: lines.join("\n") }] };
|
|
35
|
+
}
|
|
36
|
+
catch (e) {
|
|
37
|
+
return { content: [{ type: "text", text: `Error: ${e.message}` }], isError: true };
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
server.tool("get_reminders", "Get bookmarks with active reminders that are due.", {
|
|
41
|
+
page: z.number().optional().describe("Page number (default 1)"),
|
|
42
|
+
}, async (params) => {
|
|
43
|
+
try {
|
|
44
|
+
const qs = new URLSearchParams({ sort: "reminders" });
|
|
45
|
+
if (params.page)
|
|
46
|
+
qs.set("page", String(params.page));
|
|
47
|
+
const data = await api("GET", `/urls?${qs}`);
|
|
48
|
+
if (data.bookmarks.length === 0) {
|
|
49
|
+
return { content: [{ type: "text", text: "No reminders due." }] };
|
|
50
|
+
}
|
|
51
|
+
const lines = data.bookmarks.map((bm) => {
|
|
52
|
+
return `[${bm.id}] ${bm.title || "(untitled)"}\n URL: ${bm.url}\n Reminder: ${reminderLabel(bm.reminder)}${bm.last_visited ? `\n Last visited: ${bm.last_visited}` : ""}`;
|
|
53
|
+
});
|
|
54
|
+
lines.push(`\n--- ${data.total} reminder(s) due ---`);
|
|
55
|
+
return { content: [{ type: "text", text: lines.join("\n\n") }] };
|
|
56
|
+
}
|
|
57
|
+
catch (e) {
|
|
58
|
+
return { content: [{ type: "text", text: `Error: ${e.message}` }], isError: true };
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bookmark tools — search, add, update, delete.
|
|
3
|
+
*/
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { api } from "../client.js";
|
|
6
|
+
function formatBookmark(bm) {
|
|
7
|
+
const parts = [`[${bm.id}] ${bm.title || "(untitled)"}`];
|
|
8
|
+
parts.push(` URL: ${bm.url}`);
|
|
9
|
+
if (bm.keywords)
|
|
10
|
+
parts.push(` Keywords: ${bm.keywords}`);
|
|
11
|
+
if (bm.folder_id)
|
|
12
|
+
parts.push(` Folder ID: ${bm.folder_id}`);
|
|
13
|
+
if (bm.rating)
|
|
14
|
+
parts.push(` Rating: ${"*".repeat(bm.rating)}`);
|
|
15
|
+
if (bm.is_favorite)
|
|
16
|
+
parts.push(` Favorite: yes`);
|
|
17
|
+
if (bm.last_visited)
|
|
18
|
+
parts.push(` Last visited: ${bm.last_visited}`);
|
|
19
|
+
return parts.join("\n");
|
|
20
|
+
}
|
|
21
|
+
export function registerBookmarkTools(server) {
|
|
22
|
+
server.tool("search_bookmarks", "Search and list bookmarks. Use sort=visit_count_desc for most used, sort=visit_date_desc for last visited, sort=date_desc for newest, sort=rating_desc for top rated. Omit q to list all bookmarks with the given sort/filter.", {
|
|
23
|
+
q: z.string().optional().describe("Search query (searches title, URL, keywords, comments). Omit to list all."),
|
|
24
|
+
folder_id: z.number().optional().describe("Filter by folder ID"),
|
|
25
|
+
sort: z.enum(["title_asc", "date_desc", "visit_date_desc", "visit_count_desc", "rating_desc"]).optional().describe("title_asc=alphabetical, date_desc=newest first, visit_date_desc=last visited first, visit_count_desc=most visited first, rating_desc=highest rated first"),
|
|
26
|
+
page: z.number().optional().describe("Page number (default 1)"),
|
|
27
|
+
page_size: z.number().optional().describe("Items per page (default 50, max 200)"),
|
|
28
|
+
favorite: z.boolean().optional().describe("Filter favorites only"),
|
|
29
|
+
}, async (params) => {
|
|
30
|
+
try {
|
|
31
|
+
const qs = new URLSearchParams();
|
|
32
|
+
if (params.q)
|
|
33
|
+
qs.set("q", params.q);
|
|
34
|
+
if (params.folder_id !== undefined)
|
|
35
|
+
qs.set("folder_id", String(params.folder_id));
|
|
36
|
+
if (params.sort)
|
|
37
|
+
qs.set("sort", params.sort);
|
|
38
|
+
if (params.page)
|
|
39
|
+
qs.set("page", String(params.page));
|
|
40
|
+
if (params.page_size)
|
|
41
|
+
qs.set("page_size", String(params.page_size));
|
|
42
|
+
if (params.favorite)
|
|
43
|
+
qs.set("favorite", "true");
|
|
44
|
+
const query = qs.toString();
|
|
45
|
+
const data = await api("GET", `/urls${query ? "?" + query : ""}`);
|
|
46
|
+
const lines = data.bookmarks.map(formatBookmark);
|
|
47
|
+
lines.push(`\n--- Page ${data.page}, ${data.total} total ---`);
|
|
48
|
+
return { content: [{ type: "text", text: lines.join("\n\n") || "No bookmarks found." }] };
|
|
49
|
+
}
|
|
50
|
+
catch (e) {
|
|
51
|
+
return { content: [{ type: "text", text: `Error: ${e.message}` }], isError: true };
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
server.tool("add_bookmark", "Save a new bookmark. Title is auto-fetched from the page if omitted.", {
|
|
55
|
+
url: z.string().describe("URL to bookmark"),
|
|
56
|
+
title: z.string().optional().describe("Bookmark title (auto-fetched if omitted)"),
|
|
57
|
+
folder_id: z.number().optional().describe("Folder ID (0 = root)"),
|
|
58
|
+
keywords: z.string().optional().describe("Space-separated keywords/tags"),
|
|
59
|
+
comments: z.string().optional().describe("Notes or description"),
|
|
60
|
+
}, async (params) => {
|
|
61
|
+
try {
|
|
62
|
+
const body = { url: params.url };
|
|
63
|
+
if (params.title)
|
|
64
|
+
body.title = params.title;
|
|
65
|
+
if (params.folder_id !== undefined)
|
|
66
|
+
body.folder_id = params.folder_id;
|
|
67
|
+
if (params.keywords)
|
|
68
|
+
body.keywords = params.keywords;
|
|
69
|
+
if (params.comments)
|
|
70
|
+
body.comments = params.comments;
|
|
71
|
+
const bm = await api("POST", "/urls", body);
|
|
72
|
+
return { content: [{ type: "text", text: `Bookmark created:\n${formatBookmark(bm)}` }] };
|
|
73
|
+
}
|
|
74
|
+
catch (e) {
|
|
75
|
+
return { content: [{ type: "text", text: `Error: ${e.message}` }], isError: true };
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
server.tool("update_bookmark", "Update a bookmark's metadata. Only include the fields you want to change.", {
|
|
79
|
+
id: z.number().describe("Bookmark ID"),
|
|
80
|
+
url: z.string().optional().describe("New URL"),
|
|
81
|
+
title: z.string().optional().describe("New title"),
|
|
82
|
+
folder_id: z.number().optional().describe("Move to folder ID"),
|
|
83
|
+
keywords: z.string().optional().describe("New keywords (replaces all)"),
|
|
84
|
+
comments: z.string().optional().describe("New comments"),
|
|
85
|
+
rating: z.number().min(0).max(5).optional().describe("Rating 0-5"),
|
|
86
|
+
is_favorite: z.boolean().optional().describe("Favorite flag"),
|
|
87
|
+
reminder: z.number().optional().describe("Reminder: 0=daily, -1=Monday..-7=Sunday, -11..-41=1st..31st of month, positive N=every N days, -9=off"),
|
|
88
|
+
}, async (params) => {
|
|
89
|
+
try {
|
|
90
|
+
const { id, ...fields } = params;
|
|
91
|
+
const body = Object.fromEntries(Object.entries(fields).filter(([, v]) => v !== undefined));
|
|
92
|
+
const bm = await api("PATCH", `/urls/${id}`, body);
|
|
93
|
+
return { content: [{ type: "text", text: `Bookmark updated:\n${formatBookmark(bm)}` }] };
|
|
94
|
+
}
|
|
95
|
+
catch (e) {
|
|
96
|
+
return { content: [{ type: "text", text: `Error: ${e.message}` }], isError: true };
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
server.tool("delete_bookmark", "Delete a bookmark by ID.", {
|
|
100
|
+
id: z.number().describe("Bookmark ID to delete"),
|
|
101
|
+
}, async (params) => {
|
|
102
|
+
try {
|
|
103
|
+
await api("DELETE", `/urls/${params.id}`);
|
|
104
|
+
return { content: [{ type: "text", text: `Bookmark ${params.id} deleted.` }] };
|
|
105
|
+
}
|
|
106
|
+
catch (e) {
|
|
107
|
+
return { content: [{ type: "text", text: `Error: ${e.message}` }], isError: true };
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bulk tools — move bookmarks, tag bookmarks.
|
|
3
|
+
*/
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { api } from "../client.js";
|
|
6
|
+
export function registerBulkTools(server) {
|
|
7
|
+
server.tool("move_bookmarks", "Move one or more bookmarks to a folder.", {
|
|
8
|
+
ids: z.array(z.number()).describe("Bookmark IDs to move"),
|
|
9
|
+
folder_id: z.number().describe("Target folder ID (0 = root)"),
|
|
10
|
+
}, async (params) => {
|
|
11
|
+
try {
|
|
12
|
+
const result = await api("POST", "/urls/move", {
|
|
13
|
+
ids: params.ids,
|
|
14
|
+
folder_id: params.folder_id,
|
|
15
|
+
});
|
|
16
|
+
return { content: [{ type: "text", text: `Moved ${result.moved} bookmark(s) to folder ${params.folder_id}.` }] };
|
|
17
|
+
}
|
|
18
|
+
catch (e) {
|
|
19
|
+
return { content: [{ type: "text", text: `Error: ${e.message}` }], isError: true };
|
|
20
|
+
}
|
|
21
|
+
});
|
|
22
|
+
server.tool("tag_bookmarks", "Add or remove keyword tags on one or more bookmarks.", {
|
|
23
|
+
ids: z.array(z.number()).describe("Bookmark IDs to tag"),
|
|
24
|
+
add: z.array(z.string()).optional().describe("Tags to add"),
|
|
25
|
+
remove: z.array(z.string()).optional().describe("Tags to remove"),
|
|
26
|
+
}, async (params) => {
|
|
27
|
+
try {
|
|
28
|
+
const result = await api("POST", "/urls/tag", {
|
|
29
|
+
ids: params.ids,
|
|
30
|
+
add: params.add || [],
|
|
31
|
+
remove: params.remove || [],
|
|
32
|
+
});
|
|
33
|
+
return { content: [{ type: "text", text: `Modified tags on ${result.modified} bookmark(s).` }] };
|
|
34
|
+
}
|
|
35
|
+
catch (e) {
|
|
36
|
+
return { content: [{ type: "text", text: `Error: ${e.message}` }], isError: true };
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Folder tools — create, rename, delete.
|
|
3
|
+
*/
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { api } from "../client.js";
|
|
6
|
+
function buildTree(folders, parentId, indent) {
|
|
7
|
+
const children = folders.filter((f) => f.folder_id === parentId);
|
|
8
|
+
const lines = [];
|
|
9
|
+
for (const f of children) {
|
|
10
|
+
const pub = f.is_public ? " (public)" : "";
|
|
11
|
+
lines.push(`${indent}[${f.id}] ${f.name}${pub}`);
|
|
12
|
+
lines.push(...buildTree(folders, f.id, indent + " "));
|
|
13
|
+
}
|
|
14
|
+
return lines;
|
|
15
|
+
}
|
|
16
|
+
export function registerFolderTools(server) {
|
|
17
|
+
server.tool("list_folders", "List folders. With no arguments returns the full folder tree. With parent_id returns only direct children of that folder.", {
|
|
18
|
+
parent_id: z.number().optional().describe("Parent folder ID to list children of. Omit for full tree. Use 0 for root-level folders."),
|
|
19
|
+
}, async (params) => {
|
|
20
|
+
try {
|
|
21
|
+
const folders = await api("GET", "/folders");
|
|
22
|
+
if (params.parent_id !== undefined) {
|
|
23
|
+
const children = folders.filter((f) => f.folder_id === params.parent_id);
|
|
24
|
+
if (children.length === 0) {
|
|
25
|
+
return { content: [{ type: "text", text: "No subfolders found." }] };
|
|
26
|
+
}
|
|
27
|
+
const lines = children.map((f) => `[${f.id}] ${f.name}${f.is_public ? " (public)" : ""}`);
|
|
28
|
+
return { content: [{ type: "text", text: lines.join("\n") }] };
|
|
29
|
+
}
|
|
30
|
+
// Full tree
|
|
31
|
+
const lines = buildTree(folders, 0, "");
|
|
32
|
+
return { content: [{ type: "text", text: lines.join("\n") || "No folders." }] };
|
|
33
|
+
}
|
|
34
|
+
catch (e) {
|
|
35
|
+
return { content: [{ type: "text", text: `Error: ${e.message}` }], isError: true };
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
server.tool("create_folder", "Create a folder. Use 'path' for nested creation (e.g. 'Dev/Python/Libraries') or 'name' + 'parent_id' for a single folder.", {
|
|
39
|
+
name: z.string().optional().describe("Folder name (for single folder creation)"),
|
|
40
|
+
parent_id: z.number().optional().describe("Parent folder ID (0 = root, used with 'name')"),
|
|
41
|
+
path: z.string().optional().describe("Slash-separated path (e.g. 'Dev/Python/Libraries') — creates intermediates"),
|
|
42
|
+
}, async (params) => {
|
|
43
|
+
try {
|
|
44
|
+
let folder;
|
|
45
|
+
if (params.path) {
|
|
46
|
+
folder = await api("POST", "/folders/create-path", { path: params.path });
|
|
47
|
+
}
|
|
48
|
+
else if (params.name) {
|
|
49
|
+
folder = await api("POST", "/folders", {
|
|
50
|
+
name: params.name,
|
|
51
|
+
folder_id: params.parent_id || 0,
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
else {
|
|
55
|
+
return { content: [{ type: "text", text: "Error: Provide either 'path' or 'name'." }], isError: true };
|
|
56
|
+
}
|
|
57
|
+
return { content: [{ type: "text", text: `Folder created: [${folder.id}] ${folder.name}` }] };
|
|
58
|
+
}
|
|
59
|
+
catch (e) {
|
|
60
|
+
return { content: [{ type: "text", text: `Error: ${e.message}` }], isError: true };
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
server.tool("rename_folder", "Rename a folder.", {
|
|
64
|
+
id: z.number().describe("Folder ID"),
|
|
65
|
+
name: z.string().describe("New folder name"),
|
|
66
|
+
}, async (params) => {
|
|
67
|
+
try {
|
|
68
|
+
// GET current folder to preserve other fields
|
|
69
|
+
const current = await api("GET", `/folders/${params.id}`);
|
|
70
|
+
const updated = await api("PUT", `/folders/${params.id}`, {
|
|
71
|
+
name: params.name,
|
|
72
|
+
folder_id: current.folder_id,
|
|
73
|
+
is_public: current.is_public,
|
|
74
|
+
comments: current.comments,
|
|
75
|
+
});
|
|
76
|
+
return { content: [{ type: "text", text: `Folder renamed: [${updated.id}] ${updated.name}` }] };
|
|
77
|
+
}
|
|
78
|
+
catch (e) {
|
|
79
|
+
return { content: [{ type: "text", text: `Error: ${e.message}` }], isError: true };
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
server.tool("move_folder", "Move a folder to a new parent folder.", {
|
|
83
|
+
id: z.number().describe("Folder ID to move"),
|
|
84
|
+
parent_id: z.number().describe("New parent folder ID (0 = root)"),
|
|
85
|
+
}, async (params) => {
|
|
86
|
+
try {
|
|
87
|
+
const current = await api("GET", `/folders/${params.id}`);
|
|
88
|
+
const updated = await api("PUT", `/folders/${params.id}`, {
|
|
89
|
+
name: current.name,
|
|
90
|
+
folder_id: params.parent_id,
|
|
91
|
+
is_public: current.is_public,
|
|
92
|
+
comments: current.comments,
|
|
93
|
+
});
|
|
94
|
+
return { content: [{ type: "text", text: `Folder [${updated.id}] ${updated.name} moved to parent ${params.parent_id}.` }] };
|
|
95
|
+
}
|
|
96
|
+
catch (e) {
|
|
97
|
+
return { content: [{ type: "text", text: `Error: ${e.message}` }], isError: true };
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
server.tool("delete_folder", "Delete a folder and all its contents (bookmarks and subfolders).", {
|
|
101
|
+
id: z.number().describe("Folder ID to delete"),
|
|
102
|
+
}, async (params) => {
|
|
103
|
+
try {
|
|
104
|
+
await api("DELETE", `/folders/${params.id}`);
|
|
105
|
+
return { content: [{ type: "text", text: `Folder ${params.id} deleted.` }] };
|
|
106
|
+
}
|
|
107
|
+
catch (e) {
|
|
108
|
+
return { content: [{ type: "text", text: `Error: ${e.message}` }], isError: true };
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Import/export tools — XBEL export, bookmark import.
|
|
3
|
+
*/
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { api } from "../client.js";
|
|
6
|
+
export function registerImportExportTools(server) {
|
|
7
|
+
server.tool("export_bookmarks", "Export bookmarks as XBEL XML or Netscape HTML. Optionally export only a specific folder and its subfolders.", {
|
|
8
|
+
format: z.enum(["xbel", "netscape"]).optional().describe("Export format (default: xbel)"),
|
|
9
|
+
folder_id: z.number().optional().describe("Export only this folder and its subfolders. Omit for full export."),
|
|
10
|
+
}, async (params) => {
|
|
11
|
+
try {
|
|
12
|
+
const fmt = params.format || "xbel";
|
|
13
|
+
const qs = params.folder_id !== undefined ? `?folder_id=${params.folder_id}` : "";
|
|
14
|
+
const content = await api("GET", `/export/${fmt}${qs}`);
|
|
15
|
+
return { content: [{ type: "text", text: content }] };
|
|
16
|
+
}
|
|
17
|
+
catch (e) {
|
|
18
|
+
return { content: [{ type: "text", text: `Error: ${e.message}` }], isError: true };
|
|
19
|
+
}
|
|
20
|
+
});
|
|
21
|
+
server.tool("import_bookmarks", "Import bookmarks from XBEL XML or Netscape HTML content.", {
|
|
22
|
+
content: z.string().describe("The bookmark file content (XML for XBEL, HTML for Netscape)"),
|
|
23
|
+
format: z.enum(["xbel", "netscape"]).describe("File format: 'xbel' or 'netscape'"),
|
|
24
|
+
}, async (params) => {
|
|
25
|
+
try {
|
|
26
|
+
const endpoint = params.format === "xbel" ? "/import/xbel" : "/import/netscape";
|
|
27
|
+
const ct = params.format === "xbel" ? "application/xml" : "text/html";
|
|
28
|
+
const result = await api("POST", endpoint, params.content, ct);
|
|
29
|
+
return {
|
|
30
|
+
content: [{
|
|
31
|
+
type: "text",
|
|
32
|
+
text: `Import complete: ${result.bookmarks_created} bookmarks created, ${result.folders_created} folders created, ${result.bookmarks_skipped} duplicates skipped.`,
|
|
33
|
+
}],
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
catch (e) {
|
|
37
|
+
return { content: [{ type: "text", text: `Error: ${e.message}` }], isError: true };
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "linkagogo-mcp",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "MCP server for LinkaGoGo — manage bookmarks with Claude AI",
|
|
6
|
+
"keywords": ["mcp", "bookmarks", "claude", "ai", "linkagogo", "model-context-protocol"],
|
|
7
|
+
"license": "MIT",
|
|
8
|
+
"author": "LinkaGoGo",
|
|
9
|
+
"homepage": "https://www.linkagogo.com/mcp-setup",
|
|
10
|
+
"repository": {
|
|
11
|
+
"type": "git",
|
|
12
|
+
"url": "https://github.com/hbroek/lgg-claude.git",
|
|
13
|
+
"directory": "mcp"
|
|
14
|
+
},
|
|
15
|
+
"bin": {
|
|
16
|
+
"linkagogo-mcp": "./dist/index.js"
|
|
17
|
+
},
|
|
18
|
+
"files": [
|
|
19
|
+
"dist"
|
|
20
|
+
],
|
|
21
|
+
"scripts": {
|
|
22
|
+
"build": "tsc",
|
|
23
|
+
"prepublishOnly": "npm run build",
|
|
24
|
+
"dev": "tsx src/index.ts",
|
|
25
|
+
"start": "node dist/index.js"
|
|
26
|
+
},
|
|
27
|
+
"dependencies": {
|
|
28
|
+
"@modelcontextprotocol/sdk": "^1.12.0",
|
|
29
|
+
"zod": "^3.24.0"
|
|
30
|
+
},
|
|
31
|
+
"devDependencies": {
|
|
32
|
+
"@types/node": "^22.0.0",
|
|
33
|
+
"tsx": "^4.19.0",
|
|
34
|
+
"typescript": "^5.7.0"
|
|
35
|
+
}
|
|
36
|
+
}
|