#
tokens: 10386/50000 8/8 files
lines: on (toggle) GitHub
raw markdown copy reset
# Directory Structure

```
├── .gitignore
├── .python-version
├── CODE_OF_CONDUCT.md
├── CONTRIBUTING.md
├── LICENSE.txt
├── pyproject.toml
├── README.md
├── server.py
└── uv.lock
```

# Files

--------------------------------------------------------------------------------
/.python-version:
--------------------------------------------------------------------------------

```
1 | 3.12
2 | 
```

--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------

```
 1 | # macOS system files
 2 | .DS_Store
 3 | .AppleDouble
 4 | .LSOverride
 5 | Icon
 6 | ._*
 7 | .Spotlight-V100
 8 | .Trashes
 9 | .fseventsd
10 | .TemporaryItems
11 | .VolumeIcon.icns
12 | .com.apple.timemachine.donotpresent
13 | 
14 | # Python-generated files
15 | __pycache__/
16 | *.py[oc]
17 | build/
18 | dist/
19 | wheels/
20 | *.egg-info
21 | 
22 | # Virtual environments
23 | .venv
24 | 
25 | 
26 | # uv specific
27 | .uv/
28 | .venv/
29 | venv/
30 | ENV/
31 | .uuid/
32 | 
33 | # IDE specific files
34 | .idea/
35 | .vscode/
36 | *.swp
37 | *.swo
38 | *~
39 | 
40 | # Environment variables
41 | .env
42 | 
```

--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------

```markdown
  1 | # Raindrop MCP Server
  2 | 
  3 | This is a Model Context Protocol (MCP) server for Raindrop.io powered by the [Python MCP SDK](https://github.com/modelcontextprotocol/python-sdk/tree/main). It provides an easy way to read and update your bookmarks from the Raindrop personal knowledge management system in simple, human language. This can be paired with the [Firecrawl MCP server](https://github.com/mendableai/firecrawl-mcp-server#) to read the URLs associated with your bookmarks and classify them accordingly.
  4 | 
  5 | ## Requirements
  6 | 
  7 | - Python 3.12+
  8 | - [uv](https://github.com/astral-sh/uv) package manager
  9 | - [Claude Desktop](https://claude.ai/desktop)
 10 | - A Raindrop.io account and API token
 11 | 
 12 | ## Setup
 13 | 
 14 | ### 1. Obtain a Raindrop API Token
 15 | 
 16 | 1. Go to [Raindrop.io Developer Portal](https://app.raindrop.io/settings/integrations)
 17 | 2. Create a new app
 18 | 3. Copy your API token
 19 | 
 20 | ### 2. Set Your API Token
 21 | 
 22 | Set your Raindrop API token as an environment variable:
 23 | 
 24 | 1. Create a .env file in the root directory
 25 | 2. Add new line: ```RAINDROP_TOKEN="your_token_here"```
 26 | 
 27 | 
 28 | ## Development
 29 | 
 30 | To run the server in development mode:
 31 | 
 32 | ```
 33 | uv run mcp dev server.py
 34 | ```
 35 | 
 36 | ## Installation
 37 | 
 38 | To install the server to Claude Desktop:
 39 | 
 40 | ```
 41 | uv run mcp install server.py
 42 | ```
 43 | 
 44 | This will start the server locally and allow you to test changes.
 45 | 
 46 | ## Features
 47 | 
 48 | The server provides:
 49 | 
 50 | - Access to your Raindrop collections and raindrop data through capabilities
 51 | - Support for viewing root collections, child collections, or a specific collection by ID
 52 | - Tools to create, update, and delete collections and raindrops
 53 | - Tools to create and update new tags
 54 | 
 55 | ## Example Queries
 56 | 
 57 | After installing the server to Claude Desktop, you can ask Claude questions and commands like:
 58 | 
 59 | - "Show me all my Raindrop collections"
 60 | - "Do I have any collections related to programming?"
 61 | - "Add this tag to all raindrops in this collection"
 62 | - "Show me the details of my Raindrop collection with ID 12345"
 63 | - "What child collections do I have in Raindrop?"
 64 | - "Create a new Raindrop collection called 'Claude Resources'"
 65 | 
 66 | Here is some example usage in Claude Desktop (paired with a Firecrawl MCP server):
 67 | 
 68 | Input to Claude Desktop as the classificaiton system:
 69 | ![classifier](https://github.com/user-attachments/assets/648d587f-6e10-42b3-b759-878110ce1d66)
 70 | 
 71 | Output from Claude Desktop:
 72 | ![classifier-output](https://github.com/user-attachments/assets/60d67757-cda5-472b-895d-c31b1fdd3631)
 73 | 
 74 | 
 75 | ## Tools
 76 | 
 77 | The server provides the following MCP tools that let Claude Desktop perform actions on your Raindrop collections:
 78 | 
 79 | ### create_collection
 80 | 
 81 | Creates a new collection in Raindrop.io.
 82 | 
 83 | **Parameters:**
 84 | - `title` (required): Name of the collection
 85 | - `view`: View type (list, grid, masonry, simple)
 86 | - `public`: Whether the collection is public
 87 | - `parent_id`: ID of parent collection (omit for root collection)
 88 | 
 89 | ### update_collection
 90 | 
 91 | Updates an existing collection in Raindrop.io.
 92 | 
 93 | **Parameters:**
 94 | - `collection_id` (required): ID of the collection to update
 95 | - `title`: New name for the collection
 96 | - `view`: View type (list, grid, masonry, simple)
 97 | - `public`: Whether the collection is public
 98 | - `parent_id`: ID of parent collection (omit for root collection)
 99 | - `expanded`: Whether the collection is expanded
100 | 
101 | ### delete_collection
102 | 
103 | Deletes a collection from Raindrop.io. The raindrops will be moved to Trash.
104 | 
105 | **Parameters:**
106 | - `collection_id` (required): ID of the collection to delete
107 | 
108 | ### empty_trash
109 | 
110 | Empties the trash in Raindrop.io, permanently deleting all raindrops in it.
111 | 
112 | ### get_raindrop
113 | 
114 | Gets a single raindrop from Raindrop.io by ID.
115 | 
116 | **Parameters:**
117 | - `raindrop_id` (required): ID of the raindrop to fetch
118 | 
119 | ### get_raindrops
120 | 
121 | Gets multiple raindrops from a Raindrop.io collection.
122 | 
123 | **Parameters:**
124 | - `collection_id` (required): ID of the collection to fetch raindrops from. Use 0 for all raindrops, -1 for unsorted, -99 for trash.
125 | - `search`: Optional search query
126 | - `sort`: Sorting order (options: -created, created, score, -sort, title, -title, domain, -domain)
127 | - `page`: Page number (starting from 0)
128 | - `perpage`: Items per page (max 50)
129 | - `nested`: Whether to include raindrops from nested collections
130 | 
131 | ### get_tags
132 | 
133 | Gets tags from Raindrop.io.
134 | 
135 | **Parameters:**
136 | - `collection_id`: Optional ID of the collection to fetch tags from. When not specified, all tags from all collections will be retrieved.
137 | 
138 | ### update_raindrop
139 | 
140 | Updates an existing raindrop (bookmark) in Raindrop.io.
141 | 
142 | **Parameters:**
143 | - `raindrop_id` (required): ID of the raindrop to update
144 | - `title`: New title for the raindrop
145 | - `excerpt`: New description/excerpt
146 | - `link`: New URL
147 | - `important`: Set to True to mark as favorite
148 | - `tags`: List of tags to assign
149 | - `collection_id`: ID of collection to move the raindrop to
150 | - `cover`: URL for the cover image
151 | - `type`: Type of the raindrop
152 | - `order`: Sort order (ascending) - set to 0 to move to first place
153 | - `pleaseParse`: Set to True to reparse metadata (cover, type) in the background
154 | 
155 | ### update_many_raindrops
156 | 
157 | Updates multiple raindrops at once within a collection.
158 | 
159 | **Parameters:**
160 | - `collection_id` (required): ID of the collection containing raindrops to update
161 | - `ids`: Optional list of specific raindrop IDs to update
162 | - `important`: Set to True to mark as favorite, False to unmark
163 | - `tags`: List of tags to add (or empty list to remove all tags)
164 | - `cover`: URL for cover image (use '<screenshot>' to set screenshots for all)
165 | - `target_collection_id`: ID of collection to move raindrops to
166 | - `nested`: Include raindrops from nested collections
167 | - `search`: Optional search query to filter which raindrops to update
168 | 
169 |   
170 | ## Dependencies
171 | 
172 | Please see `pyproject.toml` for dependancies.
173 | 
174 | These will be installed automatically when using `uv run mcp install` or `uv run mcp dev`.
175 | 
176 | ## Contributing
177 | 
178 | Contributions are welcome! Here's how you can contribute to this project:
179 | 
180 | 1. Fork the repository
181 | 2. Create a new branch (`git checkout -b feature/your-feature-name`)
182 | 3. Make your changes
183 | 4. Validate they work as intended
184 | 5. Commit your changes (`git commit -m 'Add some feature'`)
185 | 6. Push to the branch (`git push origin feature/your-feature-name`)
186 | 7. Open a pull request
187 | 
188 | Please ensure your code follows the existing style and includes appropriate documentation.
189 | 
190 | ## License
191 | 
192 | This project is licensed under the MIT License - see the [LICENSE.txt](LICENSE.txt) file for details.
193 | 
194 | 
```

--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------

```markdown
 1 | # Contributing to Raindrop MCP Server
 2 | 
 3 | Thank you for your interest in contributing to the Raindrop MCP Server! Here's how you can help improve this project.
 4 | 
 5 | ## How to Contribute
 6 | 
 7 | 1. Fork the repository
 8 | 2. Create a new branch (`git checkout -b feature/your-feature-name`)
 9 | 3. Make your changes
10 | 4. Validate they work as intended
11 | 5. Commit your changes (`git commit -m 'Add some feature'`)
12 | 6. Push to the branch (`git push origin feature/your-feature-name`)
13 | 7. Open a pull request
14 | 
15 | ## Pull Request Process
16 | 
17 | 1. Ensure your code follows the existing style and includes appropriate documentation
18 | 2. Update the README.md with details of changes to the interface, if applicable
19 | 3. The version number will be updated according to [Semantic Versioning](http://semver.org/)
20 | 4. Your pull request will be merged once it has been reviewed and approved
21 | 
22 | ## Code Standards
23 | 
24 | - Follow PEP 8 style guidelines for Python code
25 | - Write meaningful commit messages
26 | - Include comments and docstrings for new functions and classes
27 | - Add tests for new functionality when possible
28 | 
29 | ## Bug Reports and Feature Requests
30 | 
31 | If you find a bug or have an idea for a new feature, please create an issue with:
32 | 
33 | - A clear and descriptive title
34 | - A detailed description of the bug or feature
35 | - Steps to reproduce the issue (for bugs)
36 | - Any relevant screenshots or error messages
37 | 
38 | ## Questions?
39 | 
40 | If you have any questions about contributing, feel free to open an issue with your question.
41 | 
42 | Thank you for contributing to make the Raindrop MCP Server better!
43 | 
```

--------------------------------------------------------------------------------
/CODE_OF_CONDUCT.md:
--------------------------------------------------------------------------------

```markdown
 1 | # Code of Conduct
 2 | 
 3 | ## Our Pledge
 4 | 
 5 | In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to make participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation.
 6 | 
 7 | ## Our Standards
 8 | 
 9 | Examples of behavior that contributes to creating a positive environment include:
10 | 
11 | * Using welcoming and inclusive language
12 | * Being respectful of differing viewpoints and experiences
13 | * Gracefully accepting constructive criticism
14 | * Focusing on what is best for the community
15 | * Showing empathy towards other community members
16 | 
17 | Examples of unacceptable behavior include:
18 | 
19 | * The use of sexualized language or imagery and unwelcome sexual attention or advances
20 | * Trolling, insulting/derogatory comments, and personal or political attacks
21 | * Public or private harassment
22 | * Publishing others' private information, such as a physical or electronic address, without explicit permission
23 | * Other conduct which could reasonably be considered inappropriate in a professional setting
24 | 
25 | ## Our Responsibilities
26 | 
27 | Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior.
28 | 
29 | ## Enforcement
30 | 
31 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team. All complaints will be reviewed and investigated and will result in a response that is deemed necessary and appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident.
32 | 
33 | ## Attribution
34 | 
35 | This Code of Conduct is adapted from the [Contributor Covenant](https://www.contributor-covenant.org), version 1.4, available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html
36 | 
```

--------------------------------------------------------------------------------
/pyproject.toml:
--------------------------------------------------------------------------------

```toml
 1 | [project]
 2 | name = "raindrop-mcp"
 3 | version = "0.1.0"
 4 | description = "Raindrop.io MCP server for Claude Desktop"
 5 | readme = "README.md"
 6 | requires-python = ">=3.12"
 7 | dependencies = [
 8 |     "mcp[cli]>=1.6.0",
 9 |     "httpx>=0.24.0",
10 |     "python-dotenv>=1.0.0",
11 | ]
12 | 
```

--------------------------------------------------------------------------------
/LICENSE.txt:
--------------------------------------------------------------------------------

```
 1 | MIT License
 2 | 
 3 | Copyright (c) 2025 ddaltn
 4 | 
 5 | Permission is hereby granted, free of charge, to any person obtaining a copy
 6 | of this software and associated documentation files (the "Software"), to deal
 7 | in the Software without restriction, including without limitation the rights
 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
 9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 | 
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 | 
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE. 
```

--------------------------------------------------------------------------------
/server.py:
--------------------------------------------------------------------------------

```python
  1 | import asyncio
  2 | import json
  3 | import os
  4 | import logging
  5 | import httpx
  6 | from typing import Any, Dict, List, Optional
  7 | from dotenv import load_dotenv
  8 | 
  9 | from mcp.server.fastmcp import FastMCP
 10 | 
 11 | # Load environment variables from .env file
 12 | load_dotenv()
 13 | 
 14 | # Set up logging
 15 | logging.basicConfig(level=logging.INFO)
 16 | logger = logging.getLogger("raindrop-mcp")
 17 | 
 18 | # Constants
 19 | API_URL = "https://api.raindrop.io/rest/v1"
 20 | 
 21 | # Create FastMCP server
 22 | mcp = FastMCP("Raindrop Collections API")
 23 | 
 24 | # Helper function to get headers with authentication token
 25 | async def get_headers():
 26 |     # Get token from .env file
 27 |     token = os.getenv("RAINDROP_TOKEN")
 28 |     if not token:
 29 |         logger.warning("RAINDROP_TOKEN not found in .env file.")
 30 |         logger.warning("Please add your Raindrop API token to the .env file.")
 31 |     
 32 |     # Use proper Bearer token format for authorization
 33 |     return {
 34 |         "Authorization": f"Bearer {token}" if token else "",
 35 |         "Content-Type": "application/json"
 36 |     }
 37 | 
 38 | # Define tools
 39 | @mcp.tool("get_root_collections")
 40 | async def get_root_collections() -> list:
 41 |     """
 42 |     Get all root collections from Raindrop.io
 43 |     """
 44 |     # Fetch collections from root endpoint
 45 |     headers = await get_headers()
 46 |     
 47 |     if not headers.get("Authorization"):
 48 |         raise ValueError("API token not set. Please check your .env file.")
 49 |     
 50 |     try:
 51 |         async with httpx.AsyncClient() as client:
 52 |             response = await client.get(f"{API_URL}/collections", headers=headers)
 53 |             
 54 |             if response.status_code != 200:
 55 |                 raise ValueError(f"API returned status {response.status_code}")
 56 |             
 57 |             data = response.json()
 58 |             
 59 |             if "items" not in data:
 60 |                 raise ValueError("Unexpected API response format")
 61 |             
 62 |             # Format response - return native Python list
 63 |             return [
 64 |                 {
 65 |                     "_id": c.get("_id", ""),
 66 |                     "title": c.get("title", ""),
 67 |                     "count": c.get("count", 0),
 68 |                     "public": c.get("public", False),
 69 |                     "view": c.get("view", ""),
 70 |                     "color": c.get("color", ""),
 71 |                     "created": c.get("created", ""),
 72 |                     "lastUpdate": c.get("lastUpdate", ""),
 73 |                     "expanded": c.get("expanded", False)
 74 |                 }
 75 |                 for c in data["items"]
 76 |             ]
 77 |     except Exception as e:
 78 |         raise ValueError(f"Error fetching root collections: {str(e)}")
 79 | 
 80 | @mcp.tool("get_child_collections")
 81 | async def get_child_collections() -> str:
 82 |     """
 83 |     Get all child collections from Raindrop.io
 84 |     """
 85 |     headers = await get_headers()
 86 |     
 87 |     if not headers.get("Authorization"):
 88 |         return json.dumps({
 89 |             "isError": True,
 90 |             "content": [{"type": "text", "text": "API token not set. Please check your .env file."}]
 91 |         })
 92 |     
 93 |     try:
 94 |         async with httpx.AsyncClient() as client:
 95 |             response = await client.get(f"{API_URL}/collections/childrens", headers=headers)
 96 |             
 97 |             if response.status_code != 200:
 98 |                 return json.dumps({
 99 |                     "isError": True,
100 |                     "content": [{"type": "text", "text": f"Error: API returned status {response.status_code}"}]
101 |                 })
102 |             
103 |             data = response.json()
104 |             
105 |             if "items" not in data:
106 |                 return json.dumps({
107 |                     "isError": True,
108 |                     "content": [{"type": "text", "text": "Error: Unexpected API response format"}]
109 |                 })
110 |             
111 |             # Format response
112 |             result = [
113 |                 {
114 |                     "_id": c.get("_id", ""),
115 |                     "title": c.get("title", ""),
116 |                     "count": c.get("count", 0),
117 |                     "public": c.get("public", False),
118 |                     "view": c.get("view", ""),
119 |                     "color": c.get("color", ""),
120 |                     "parent_id": c.get("parent", {}).get("$id", None),
121 |                     "created": c.get("created", ""),
122 |                     "lastUpdate": c.get("lastUpdate", ""),
123 |                     "expanded": c.get("expanded", False)
124 |                 }
125 |                 for c in data["items"]
126 |             ]
127 |             
128 |             return json.dumps({
129 |                 "content": [
130 |                     {"type": "text", "text": f"Found {len(result)} child collection(s)"},
131 |                     {"type": "json", "json": result}
132 |                 ]
133 |             })
134 |     except Exception as e:
135 |         return json.dumps({
136 |             "isError": True,
137 |             "content": [{"type": "text", "text": f"Error: {str(e)}"}]
138 |         })
139 | 
140 | @mcp.tool("get_collection_by_id")
141 | async def get_collection_by_id(collection_id: int) -> dict:
142 |     """
143 |     Get a specific collection from Raindrop.io by ID
144 |     
145 |     Args:
146 |         collection_id: ID of the collection to fetch
147 |     """
148 |     # Validate input
149 |     if collection_id is None:
150 |         raise ValueError("No collection ID provided")
151 |     
152 |     # Fetch specific collection by ID
153 |     headers = await get_headers()
154 |     
155 |     if not headers.get("Authorization"):
156 |         raise ValueError("API token not set. Please check your .env file.")
157 |     
158 |     try:
159 |         async with httpx.AsyncClient() as client:
160 |             response = await client.get(f"{API_URL}/collection/{collection_id}", headers=headers)
161 |             
162 |             if response.status_code != 200:
163 |                 raise ValueError(f"API returned status {response.status_code}")
164 |             
165 |             data = response.json()
166 |             
167 |             if "item" not in data:
168 |                 raise ValueError("Unexpected API response format")
169 |             
170 |             # Format the single collection response
171 |             collection = data["item"]
172 |             result = {
173 |                 "_id": collection.get("_id", ""),
174 |                 "title": collection.get("title", ""),
175 |                 "count": collection.get("count", 0),
176 |                 "public": collection.get("public", False),
177 |                 "view": collection.get("view", ""),
178 |                 "color": collection.get("color", ""),
179 |                 "created": collection.get("created", ""),
180 |                 "lastUpdate": collection.get("lastUpdate", ""),
181 |                 "expanded": collection.get("expanded", False)
182 |             }
183 |             
184 |             # Add parent ID if present
185 |             if "parent" in collection and "$id" in collection["parent"]:
186 |                 result["parent_id"] = collection["parent"]["$id"]
187 |             
188 |             # Return native Python dict instead of JSON string
189 |             return result
190 |     except Exception as e:
191 |         # Let exceptions bubble up to MCP
192 |         raise ValueError(f"Error fetching collection: {str(e)}")
193 | 
194 | @mcp.tool("create_collection")
195 | async def create_collection(
196 |     title: str, 
197 |     view: str = "list", 
198 |     public: bool = False, 
199 |     parent_id: Optional[int] = None
200 | ) -> str:
201 |     """
202 |     Create a new collection in Raindrop.io
203 |     
204 |     Args:
205 |         title: Name of the collection
206 |         view: View type (list, grid, masonry, simple)
207 |         public: Whether the collection is public
208 |         parent_id: ID of parent collection (omit for root collection)
209 |     """
210 |     headers = await get_headers()
211 |     
212 |     if not headers.get("Authorization"):
213 |         return json.dumps({
214 |             "isError": True,
215 |             "content": [{"type": "text", "text": "API token not set. Please check your .env file."}]
216 |         })
217 |         
218 |     try:
219 |         data = {
220 |             "title": title,
221 |             "view": view,
222 |             "public": public
223 |         }
224 |         
225 |         if parent_id is not None:
226 |             data["parent"] = {"$id": parent_id}
227 |             
228 |         async with httpx.AsyncClient() as client:
229 |             response = await client.post(
230 |                 f"{API_URL}/collection",
231 |                 headers=headers,
232 |                 json=data
233 |             )
234 |             response_data = response.json()
235 |             
236 |             if not response_data.get("result", False):
237 |                 return json.dumps({
238 |                     "isError": True,
239 |                     "content": [{"type": "text", "text": f"Error creating collection: {response_data.get('errorMessage', 'Unknown error')}"}]
240 |                 })
241 |             
242 |             return json.dumps({
243 |                 "content": [
244 |                     {"type": "text", "text": f"Collection '{title}' created successfully."},
245 |                     {"type": "text", "text": f"Collection ID: {response_data.get('item', {}).get('_id', 'Unknown')}"}
246 |                 ]
247 |             })
248 |     except Exception as e:
249 |         raise e  # Let exceptions bubble up
250 |             
251 | @mcp.tool("update_collection")
252 | async def update_collection(
253 |     collection_id: int, 
254 |     title: Optional[str] = None,
255 |     view: Optional[str] = None,
256 |     public: Optional[bool] = None,
257 |     parent_id: Optional[int] = None,
258 |     expanded: Optional[bool] = None
259 | ) -> str:
260 |     """
261 |     Update an existing collection in Raindrop.io
262 |     
263 |     Args:
264 |         collection_id: ID of the collection to update
265 |         title: New name for the collection
266 |         view: View type (list, grid, masonry, simple)
267 |         public: Whether the collection is public
268 |         parent_id: ID of parent collection (omit for root collection)
269 |         expanded: Whether the collection is expanded
270 |     """
271 |     headers = await get_headers()
272 |     
273 |     if not headers.get("Authorization"):
274 |         return json.dumps({
275 |             "isError": True,
276 |             "content": [{"type": "text", "text": "API token not set. Please check your .env file."}]
277 |         })
278 |         
279 |     try:
280 |         data = {}
281 |         if title is not None:
282 |             data["title"] = title
283 |         if view is not None:
284 |             data["view"] = view
285 |         if public is not None:
286 |             data["public"] = public
287 |         if parent_id is not None:
288 |             data["parent"] = {"$id": parent_id}
289 |         if expanded is not None:
290 |             data["expanded"] = expanded
291 |             
292 |         if not data:
293 |             return json.dumps({
294 |                 "isError": True,
295 |                 "content": [{"type": "text", "text": "No update parameters provided."}]
296 |             })
297 |             
298 |         async with httpx.AsyncClient() as client:
299 |             response = await client.put(
300 |                 f"{API_URL}/collection/{collection_id}",
301 |                 headers=headers,
302 |                 json=data
303 |             )
304 |             response_data = response.json()
305 |             
306 |             if not response_data.get("result", False):
307 |                 return json.dumps({
308 |                     "isError": True,
309 |                     "content": [{"type": "text", "text": f"Error updating collection: {response_data.get('errorMessage', 'Unknown error')}"}]
310 |                 })
311 |             
312 |             return json.dumps({
313 |                 "content": [
314 |                     {"type": "text", "text": f"Collection {collection_id} updated successfully."}
315 |                 ]
316 |             })
317 |     except Exception as e:
318 |         raise e  # Let exceptions bubble up
319 |     
320 | @mcp.tool("delete_collection")
321 | async def delete_collection(collection_id: int) -> str:
322 |     """
323 |     Delete a collection from Raindrop.io. The raindrops will be moved to Trash.
324 |     
325 |     Args:
326 |         collection_id: ID of the collection to delete
327 |     """
328 |     headers = await get_headers()
329 |     
330 |     if not headers.get("Authorization"):
331 |         return json.dumps({
332 |             "isError": True,
333 |             "content": [{"type": "text", "text": "API token not set. Please check your .env file."}]
334 |         })
335 |         
336 |     try:
337 |         async with httpx.AsyncClient() as client:
338 |             response = await client.delete(
339 |                 f"{API_URL}/collection/{collection_id}",
340 |                 headers=headers
341 |             )
342 |             response_data = response.json()
343 |             
344 |             if not response_data.get("result", False):
345 |                 return json.dumps({
346 |                     "isError": True,
347 |                     "content": [{"type": "text", "text": f"Error deleting collection: {response_data.get('errorMessage', 'Unknown error')}"}]
348 |                 })
349 |             
350 |             return json.dumps({
351 |                 "content": [
352 |                     {"type": "text", "text": f"Collection {collection_id} deleted successfully."}
353 |                 ]
354 |             })
355 |     except Exception as e:
356 |         raise e  # Let exceptions bubble up
357 |             
358 | @mcp.tool("empty_trash")
359 | async def empty_trash() -> str:
360 |     """
361 |     Empty the trash in Raindrop.io, permanently deleting all raindrops in it.
362 |     """
363 |     headers = await get_headers()
364 |     
365 |     if not headers.get("Authorization"):
366 |         return json.dumps({
367 |             "isError": True,
368 |             "content": [{"type": "text", "text": "API token not set. Please check your .env file."}]
369 |         })
370 |         
371 |     try:
372 |         async with httpx.AsyncClient() as client:
373 |             response = await client.delete(
374 |                 f"{API_URL}/collection/-99",
375 |                 headers=headers
376 |             )
377 |             response_data = response.json()
378 |             
379 |             if not response_data.get("result", False):
380 |                 return json.dumps({
381 |                     "isError": True,
382 |                     "content": [{"type": "text", "text": f"Error emptying trash: {response_data.get('errorMessage', 'Unknown error')}"}]
383 |                 })
384 |             
385 |             return json.dumps({
386 |                 "content": [
387 |                     {"type": "text", "text": "Trash emptied successfully."}
388 |                 ]
389 |             })
390 |     except Exception as e:
391 |         raise e  # Let exceptions bubble up
392 | 
393 | @mcp.tool("get_raindrop")
394 | async def get_raindrop(raindrop_id: int) -> dict:
395 |     """
396 |     Get a single raindrop from Raindrop.io by ID
397 |     
398 |     Args:
399 |         raindrop_id: ID of the raindrop to fetch
400 |     """
401 |     # Validate input
402 |     if raindrop_id is None:
403 |         raise ValueError("No raindrop ID provided")
404 |     
405 |     headers = await get_headers()
406 |     
407 |     if not headers.get("Authorization"):
408 |         raise ValueError("API token not set. Please check your .env file.")
409 |     
410 |     try:
411 |         async with httpx.AsyncClient() as client:
412 |             response = await client.get(f"{API_URL}/raindrop/{raindrop_id}", headers=headers)
413 |             
414 |             if response.status_code != 200:
415 |                 raise ValueError(f"API returned status {response.status_code}")
416 |             
417 |             data = response.json()
418 |             
419 |             if "item" not in data:
420 |                 raise ValueError("Unexpected API response format")
421 |             
422 |             # Return the raindrop item directly
423 |             return data["item"]
424 |     except Exception as e:
425 |         # Let exceptions bubble up to MCP
426 |         raise ValueError(f"Error fetching raindrop: {str(e)}")
427 | 
428 | @mcp.tool("get_raindrops")
429 | async def get_raindrops(
430 |     collection_id: int,
431 |     search: Optional[str] = None,
432 |     sort: Optional[str] = None,
433 |     page: Optional[int] = 0,
434 |     perpage: Optional[int] = 25,
435 |     nested: Optional[bool] = False
436 | ) -> dict:
437 |     """
438 |     Get multiple raindrops from a Raindrop.io collection
439 |     
440 |     Args:
441 |         collection_id: ID of the collection to fetch raindrops from.
442 |                        Use 0 for all raindrops, -1 for unsorted, -99 for trash.
443 |         search: Optional search query
444 |         sort: Sorting order. Options: -created (default), created, score, -sort, title, -title, domain, -domain
445 |         page: Page number (starting from 0)
446 |         perpage: Items per page (max 50)
447 |         nested: Whether to include raindrops from nested collections
448 |     """
449 |     # Validate inputs
450 |     if collection_id is None:
451 |         raise ValueError("No collection ID provided")
452 |     
453 |     if perpage > 50:
454 |         perpage = 50  # API limit
455 |     
456 |     headers = await get_headers()
457 |     
458 |     if not headers.get("Authorization"):
459 |         raise ValueError("API token not set. Please check your .env file.")
460 |     
461 |     try:
462 |         # Build query parameters
463 |         params = {}
464 |         if search:
465 |             params["search"] = search
466 |         if sort:
467 |             params["sort"] = sort
468 |         if page is not None:
469 |             params["page"] = page
470 |         if perpage:
471 |             params["perpage"] = perpage
472 |         if nested:
473 |             params["nested"] = "true"
474 |         
475 |         async with httpx.AsyncClient() as client:
476 |             response = await client.get(
477 |                 f"{API_URL}/raindrops/{collection_id}", 
478 |                 headers=headers,
479 |                 params=params
480 |             )
481 |             
482 |             if response.status_code != 200:
483 |                 raise ValueError(f"API returned status {response.status_code}")
484 |             
485 |             return response.json()
486 |     except Exception as e:
487 |         # Let exceptions bubble up to MCP
488 |         raise ValueError(f"Error fetching raindrops: {str(e)}")
489 | 
490 | @mcp.tool("get_tags")
491 | async def get_tags(collection_id: Optional[int] = None) -> list:
492 |     """
493 |     Get tags from Raindrop.io
494 |     
495 |     Args:
496 |         collection_id: Optional ID of the collection to fetch tags from.
497 |                       When not specified, all tags from all collections will be retrieved.
498 |     """
499 |     headers = await get_headers()
500 |     
501 |     if not headers.get("Authorization"):
502 |         raise ValueError("API token not set. Please check your .env file.")
503 |     
504 |     try:
505 |         # Build the endpoint URL
506 |         endpoint = f"{API_URL}/tags"
507 |         if collection_id is not None:
508 |             endpoint = f"{endpoint}/{collection_id}"
509 |         
510 |         async with httpx.AsyncClient() as client:
511 |             response = await client.get(endpoint, headers=headers)
512 |             
513 |             if response.status_code != 200:
514 |                 raise ValueError(f"API returned status {response.status_code}")
515 |             
516 |             data = response.json()
517 |             
518 |             if not data.get("result", False) or "items" not in data:
519 |                 raise ValueError("Unexpected API response format")
520 |             
521 |             # Return just the tags array for simplicity
522 |             return data["items"]
523 |     except Exception as e:
524 |         # Let exceptions bubble up to MCP
525 |         raise ValueError(f"Error fetching tags: {str(e)}")
526 | 
527 | @mcp.tool("update_raindrop")
528 | async def update_raindrop(
529 |     raindrop_id: int,
530 |     title: Optional[str] = None,
531 |     excerpt: Optional[str] = None,
532 |     link: Optional[str] = None,
533 |     important: Optional[bool] = None,
534 |     tags: Optional[List[str]] = None,
535 |     collection_id: Optional[int] = None,
536 |     cover: Optional[str] = None,
537 |     type: Optional[str] = None,
538 |     order: Optional[int] = None,
539 |     pleaseParse: Optional[bool] = None
540 | ) -> dict:
541 |     """
542 |     Update an existing raindrop (bookmark) in Raindrop.io
543 |     
544 |     Args:
545 |         raindrop_id: ID of the raindrop to update
546 |         title: New title for the raindrop
547 |         excerpt: New description/excerpt
548 |         link: New URL
549 |         important: Set to True to mark as favorite
550 |         tags: List of tags to assign
551 |         collection_id: ID of collection to move the raindrop to
552 |         cover: URL for the cover image
553 |         type: Type of the raindrop
554 |         order: Sort order (ascending) - set to 0 to move to first place
555 |         pleaseParse: Set to True to reparse metadata (cover, type) in the background
556 |     """
557 |     # Validate input
558 |     if raindrop_id is None:
559 |         raise ValueError("No raindrop ID provided")
560 |     
561 |     headers = await get_headers()
562 |     
563 |     if not headers.get("Authorization"):
564 |         raise ValueError("API token not set. Please check your .env file.")
565 |     
566 |     try:
567 |         # Build the request body with only provided parameters
568 |         data = {}
569 |         if title is not None:
570 |             data["title"] = title
571 |         if excerpt is not None:
572 |             data["excerpt"] = excerpt
573 |         if link is not None:
574 |             data["link"] = link
575 |         if important is not None:
576 |             data["important"] = important
577 |         if tags is not None:
578 |             data["tags"] = tags
579 |         if collection_id is not None:
580 |             data["collection"] = {"$id": collection_id}
581 |         if cover is not None:
582 |             data["cover"] = cover
583 |         if type is not None:
584 |             data["type"] = type
585 |         if order is not None:
586 |             data["order"] = order
587 |         if pleaseParse:
588 |             data["pleaseParse"] = {}
589 |         
590 |         if not data:
591 |             raise ValueError("No update parameters provided")
592 |         
593 |         async with httpx.AsyncClient() as client:
594 |             response = await client.put(
595 |                 f"{API_URL}/raindrop/{raindrop_id}",
596 |                 headers=headers,
597 |                 json=data
598 |             )
599 |             
600 |             if response.status_code != 200:
601 |                 raise ValueError(f"API returned status {response.status_code}")
602 |             
603 |             result = response.json()
604 |             
605 |             if not result.get("result", False):
606 |                 error_message = result.get("errorMessage", "Unknown error")
607 |                 raise ValueError(f"Error updating raindrop: {error_message}")
608 |             
609 |             return result
610 |     except Exception as e:
611 |         # Let exceptions bubble up to MCP
612 |         raise ValueError(f"Error updating raindrop: {str(e)}")
613 | 
614 | @mcp.tool("update_many_raindrops")
615 | async def update_many_raindrops(
616 |     collection_id: int,
617 |     ids: Optional[List[int]] = None,
618 |     important: Optional[bool] = None,
619 |     tags: Optional[List[str]] = None,
620 |     cover: Optional[str] = None,
621 |     target_collection_id: Optional[int] = None,
622 |     nested: Optional[bool] = False,
623 |     search: Optional[str] = None
624 | ) -> dict:
625 |     """
626 |     Update multiple raindrops at once within a collection
627 |     
628 |     Args:
629 |         collection_id: ID of the collection containing raindrops to update
630 |         ids: Optional list of specific raindrop IDs to update
631 |         important: Set to True to mark as favorite, False to unmark
632 |         tags: List of tags to add (or empty list to remove all tags)
633 |         cover: URL for cover image (use '<screenshot>' to set screenshots for all)
634 |         target_collection_id: ID of collection to move raindrops to
635 |         nested: Include raindrops from nested collections
636 |         search: Optional search query to filter which raindrops to update
637 |     """
638 |     # Validate input
639 |     if collection_id is None:
640 |         raise ValueError("No collection ID provided")
641 |     
642 |     headers = await get_headers()
643 |     
644 |     if not headers.get("Authorization"):
645 |         raise ValueError("API token not set. Please check your .env file.")
646 |     
647 |     try:
648 |         # Build the request body with only provided parameters
649 |         data = {}
650 |         if ids is not None:
651 |             data["ids"] = ids
652 |         if important is not None:
653 |             data["important"] = important
654 |         if tags is not None:
655 |             data["tags"] = tags
656 |         if cover is not None:
657 |             data["cover"] = cover
658 |         if target_collection_id is not None:
659 |             data["collection"] = {"$id": target_collection_id}
660 |         
661 |         if not data:
662 |             raise ValueError("No update parameters provided")
663 |         
664 |         # Build query params
665 |         params = {}
666 |         if search:
667 |             params["search"] = search
668 |         if nested:
669 |             params["nested"] = "true"
670 |         
671 |         async with httpx.AsyncClient() as client:
672 |             response = await client.put(
673 |                 f"{API_URL}/raindrops/{collection_id}",
674 |                 headers=headers,
675 |                 json=data,
676 |                 params=params
677 |             )
678 |             
679 |             if response.status_code != 200:
680 |                 raise ValueError(f"API returned status {response.status_code}")
681 |             
682 |             result = response.json()
683 |             
684 |             if not result.get("result", False):
685 |                 error_message = result.get("errorMessage", "Unknown error")
686 |                 raise ValueError(f"Error updating raindrops: {error_message}")
687 |             
688 |             return result
689 |     except Exception as e:
690 |         # Let exceptions bubble up to MCP
691 |         raise ValueError(f"Error updating raindrops: {str(e)}")
692 | 
693 | if __name__ == "__main__":
694 |     asyncio.run(mcp.run()) 
```