# 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 |  70 | 71 | Output from Claude Desktop: 72 |  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()) ```