# Directory Structure
```
├── .gitignore
├── .prettierrc
├── bun.lock
├── cli.js
├── package.json
├── README.md
├── src
│ ├── index.ts
│ ├── raindrop.ts
│ ├── server.ts
│ └── utils.ts
└── tsconfig.json
```
# Files
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
```
1 | {
2 | "semi": false
3 | }
4 |
```
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
```
1 | # dependencies (bun install)
2 | node_modules
3 |
4 | # output
5 | out
6 | dist
7 | *.tgz
8 |
9 | # code coverage
10 | coverage
11 | *.lcov
12 |
13 | # logs
14 | logs
15 | _.log
16 | report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
17 |
18 | # dotenv environment variable files
19 | .env
20 | .env.development.local
21 | .env.test.local
22 | .env.production.local
23 | .env.local
24 |
25 | # caches
26 | .eslintcache
27 | .cache
28 | *.tsbuildinfo
29 |
30 | # IntelliJ based IDEs
31 | .idea
32 |
33 | # Finder (MacOS) folder config
34 | .DS_Store
35 |
```
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
```markdown
1 | # raindrop-mcp
2 |
3 | An MCP server for [Raindrop.io](https://raindrop.io).
4 |
5 | This project is sponsored by [ChatWise](https://chatwise.app), an all-in-one LLM chatbot with first-class MCP support.
6 |
7 | ## Usage
8 |
9 | Create an access token on [Raindrop.io](https://app.raindrop.io/settings/integrations):
10 |
11 | 1. create an application
12 | 2. create a test token
13 | 3. copy the test token
14 |
15 | JSON config for `raindrop-mcp` as `stdio` server:
16 |
17 | ```json
18 | {
19 | "mcpServers": {
20 | "raindrop": {
21 | "command": "npx",
22 | "args": ["-y", "raindrop-mcp"],
23 | "env": {
24 | "RAINDROP_ACCESS_TOKEN": "<your-token>"
25 | }
26 | }
27 | }
28 | }
29 | ```
30 |
31 | Alternatively you can run it as:
32 |
33 | - sse server: `npx -y raindrop-mcp --sse`
34 | - streamable http server: `npx -y raindrop-mcp --http`
35 |
36 | ## Capabilities
37 |
38 | - Search bookmarks
39 | - Create bookmarks
40 | - Get all collections
41 |
42 | ## License
43 |
44 | MIT.
45 |
```
--------------------------------------------------------------------------------
/cli.js:
--------------------------------------------------------------------------------
```javascript
1 | #!/usr/bin/env node
2 | import "./dist/index.js"
3 |
```
--------------------------------------------------------------------------------
/src/utils.ts:
--------------------------------------------------------------------------------
```typescript
1 | export const errorToToolResult = (error: unknown) => {
2 | return {
3 | content: [
4 | {
5 | type: "text" as const,
6 | text: error instanceof Error ? error.message : String(error),
7 | },
8 | ],
9 | isError: true,
10 | }
11 | }
12 |
```
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { cac } from "cac"
2 | import { version } from "../package.json"
3 | import { startServer } from "./server"
4 |
5 | const cli = cac(`fetch-mcp`)
6 |
7 | cli
8 | .command("[...args]", "Start server")
9 | .option("--sse", "Use SSE transport")
10 | .option("--http [endpoint]", "Use Streamable HTTP transport")
11 | .action(async (args, flags) => {
12 | await startServer(
13 | flags.http
14 | ? {
15 | type: "http",
16 | endpoint: typeof flags.http === "string" ? flags.http : "/mcp",
17 | }
18 | : flags.sse
19 | ? { type: "sse" }
20 | : { type: "stdio" }
21 | )
22 | })
23 |
24 | cli.version(version)
25 | cli.help()
26 | cli.parse()
27 |
```
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
```json
1 | {
2 | "compilerOptions": {
3 | // Environment setup & latest features
4 | "lib": ["ESNext"],
5 | "target": "ESNext",
6 | "module": "ESNext",
7 | "moduleDetection": "force",
8 | "jsx": "react-jsx",
9 | "allowJs": true,
10 |
11 | // Bundler mode
12 | "moduleResolution": "bundler",
13 | "allowImportingTsExtensions": true,
14 | "verbatimModuleSyntax": true,
15 | "noEmit": true,
16 |
17 | // Best practices
18 | "strict": true,
19 | "skipLibCheck": true,
20 | "noFallthroughCasesInSwitch": true,
21 | "noUncheckedIndexedAccess": true,
22 |
23 | // Some stricter flags (disabled by default)
24 | "noUnusedLocals": false,
25 | "noUnusedParameters": false,
26 | "noPropertyAccessFromIndexSignature": false
27 | }
28 | }
29 |
```
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
```json
1 | {
2 | "name": "raindrop-mcp",
3 | "description": "An MCP server for Raindrop.io by ChatWise",
4 | "type": "module",
5 | "version": "0.0.4",
6 | "bin": "./cli.js",
7 | "files": [
8 | "dist",
9 | "/cli.js"
10 | ],
11 | "scripts": {
12 | "build": "bun build src/index.ts --packages external --outdir dist --target node",
13 | "prepublishOnly": "npm run build"
14 | },
15 | "devDependencies": {
16 | "@types/bun": "latest",
17 | "@types/js-yaml": "^4.0.9",
18 | "@types/polka": "^0.5.7",
19 | "typescript": "^5"
20 | },
21 | "dependencies": {
22 | "@chatmcp/sdk": "^1.0.5",
23 | "@modelcontextprotocol/sdk": "^1.9.0",
24 | "cac": "^6.7.14",
25 | "got": "^14.4.7",
26 | "js-yaml": "^4.1.0",
27 | "polka": "^0.5.2",
28 | "zod": "^3.24.2"
29 | }
30 | }
31 |
```
--------------------------------------------------------------------------------
/src/raindrop.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { got, type Got } from "got"
2 | import { z } from "zod"
3 |
4 | export const SearchBookmarksSchema = {
5 | search: z.string().describe("The search query"),
6 | page: z.number().default(0).describe("The page number"),
7 | perpage: z.number().default(50).describe("The number of bookmarks per page"),
8 | sort: z.enum(["-created", "created"]).optional().describe(`Sort bookmarks`),
9 | collection_id: z
10 | .number()
11 | .default(0)
12 | .describe(
13 | `The collection ID to filter bookmarks, 0 for all, -1 for unsorted, -99 for trash, others for specific collection`
14 | ),
15 | }
16 |
17 | type SearchBookmarksOptions = z.infer<z.ZodObject<typeof SearchBookmarksSchema>>
18 |
19 | export const CreateBookmarksSchema = {
20 | items: z.array(
21 | z.object({
22 | link: z.string().describe("The URL of this bookmark"),
23 | title: z.string().optional().describe("The bookmark title"),
24 | excerpt: z.string().optional().describe("The excerpt"),
25 | })
26 | ),
27 | }
28 |
29 | type CreateBookmarksOptions = z.infer<z.ZodObject<typeof CreateBookmarksSchema>>
30 |
31 | export class Raindrop {
32 | got: Got
33 |
34 | constructor(apiKey: string) {
35 | this.got = got.extend({
36 | prefixUrl: "https://api.raindrop.io/rest/v1",
37 | headers: {
38 | Authorization: `Bearer ${apiKey}`,
39 | },
40 | })
41 | }
42 |
43 | async searchBookmarks({ collection_id, ...options }: SearchBookmarksOptions) {
44 | const request = this.got.get(`raindrops/${collection_id}`, {
45 | searchParams: options,
46 | })
47 | const [res, json] = await Promise.all([request, request.json()])
48 |
49 | if (!res.ok) {
50 | throw new Error(
51 | `Failed to search bookmarks: ${res.statusCode}\n${res.body}`
52 | )
53 | }
54 |
55 | return json
56 | }
57 |
58 | async createBookmarks(options: CreateBookmarksOptions) {
59 | const request = this.got.post(`raindrops`, {
60 | json: {
61 | items: options.items.map((item) => {
62 | return {
63 | ...item,
64 | // let raindrop parse the title, description, and excerpt etc in the background
65 | pleaseParse: {},
66 | }
67 | }),
68 | },
69 | })
70 |
71 | const [res, json] = await Promise.all([request, request.json()])
72 |
73 | if (!res.ok) {
74 | throw new Error(
75 | `Failed to create bookmarks: ${res.statusCode}\n${res.body}`
76 | )
77 | }
78 |
79 | return json
80 | }
81 |
82 | async getCollections() {
83 | const request = this.got.get("collections")
84 |
85 | const [res, json] = await Promise.all([request, request.json()])
86 |
87 | if (!res.ok) {
88 | throw new Error(
89 | `Failed to get collections: ${res.statusCode}\n${res.body}`
90 | )
91 | }
92 |
93 | return json
94 | }
95 | }
96 |
```
--------------------------------------------------------------------------------
/src/server.ts:
--------------------------------------------------------------------------------
```typescript
1 | import Polka from "polka"
2 | import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"
3 | import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
4 | import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js"
5 | import { RestServerTransport } from "@chatmcp/sdk/server/rest.js"
6 | import { version } from "../package.json"
7 | import {
8 | CreateBookmarksSchema,
9 | Raindrop,
10 | SearchBookmarksSchema,
11 | } from "./raindrop"
12 | import { dump } from "js-yaml"
13 | import { errorToToolResult } from "./utils"
14 |
15 | const server = new McpServer(
16 | {
17 | name: "raindrop-mcp",
18 | version,
19 | },
20 | {
21 | capabilities: {
22 | logging: {},
23 | tools: {},
24 | },
25 | }
26 | )
27 |
28 | if (!process.env.RAINDROP_ACCESS_TOKEN) {
29 | throw new Error(`RAINDROP_ACCESS_TOKEN is not set`)
30 | }
31 |
32 | const raindrop = new Raindrop(process.env.RAINDROP_ACCESS_TOKEN)
33 |
34 | server.tool(
35 | "search_bookmarks",
36 | "Search bookmarks from Raindrop.io",
37 | SearchBookmarksSchema,
38 | async (args) => {
39 | try {
40 | const res = await raindrop.searchBookmarks(args)
41 | return {
42 | content: [
43 | {
44 | type: "text",
45 | text: dump(res),
46 | },
47 | ],
48 | }
49 | } catch (error) {
50 | return errorToToolResult(error)
51 | }
52 | }
53 | )
54 |
55 | server.tool(
56 | "create_bookmarks",
57 | "Create bookmarks on Raindrop.io",
58 | CreateBookmarksSchema,
59 | async (args) => {
60 | try {
61 | const res = await raindrop.createBookmarks(args)
62 | return {
63 | content: [
64 | {
65 | type: "text",
66 | text: dump(res),
67 | },
68 | ],
69 | }
70 | } catch (error) {
71 | return errorToToolResult(error)
72 | }
73 | }
74 | )
75 |
76 | server.tool(
77 | "get_collections",
78 | "Get collections from Raindrop.io",
79 | {},
80 | async () => {
81 | try {
82 | const res = await raindrop.getCollections()
83 | return {
84 | content: [
85 | {
86 | type: "text",
87 | text: dump(res),
88 | },
89 | ],
90 | }
91 | } catch (error) {
92 | return errorToToolResult(error)
93 | }
94 | }
95 | )
96 | const port = Number(process.env.PORT || "3000")
97 |
98 | export async function startServer(
99 | options:
100 | | { type: "http"; endpoint: string }
101 | | { type: "sse" }
102 | | { type: "stdio" }
103 | ) {
104 | if (options.type === "http") {
105 | const transport = new RestServerTransport({
106 | port,
107 | endpoint: options.endpoint,
108 | })
109 | await server.connect(transport)
110 |
111 | await transport.startServer()
112 | } else if (options.type === "sse") {
113 | const transports = new Map<string, SSEServerTransport>()
114 |
115 | const app = Polka()
116 |
117 | app.get("/sse", async (req, res) => {
118 | console.log(req)
119 | const transport = new SSEServerTransport("/messages", res)
120 | transports.set(transport.sessionId, transport)
121 | res.on("close", () => {
122 | transports.delete(transport.sessionId)
123 | })
124 | await server.connect(transport)
125 | })
126 |
127 | app.post("/messages", async (req, res) => {
128 | const sessionId = req.query.sessionId as string
129 | const transport = transports.get(sessionId)
130 | if (transport) {
131 | await transport.handlePostMessage(req, res)
132 | } else {
133 | res.status(400).send("No transport found for sessionId")
134 | }
135 | })
136 |
137 | app.listen(port)
138 | console.log(`sse server: http://localhost:${port}/sse`)
139 | } else {
140 | const transport = new StdioServerTransport()
141 | await server.connect(transport)
142 | }
143 | }
144 |
```