├── .gitignore ├── CHANGELOG.md ├── IMPLEMENTATION_SUMMARY.md ├── LICENSE ├── README.md ├── RELEASE_NOTES.md ├── configure_token.py ├── pyproject.toml ├── pytest.ini ├── smithery.yaml ├── src └── things_mcp │ ├── __init__.py │ ├── applescript_bridge.py │ ├── cache.py │ ├── config.py │ ├── fast_server.py │ ├── formatters.py │ ├── handlers.py │ ├── logging_config.py │ ├── mcp_tools.py │ ├── simple_server.py │ ├── simple_url_scheme.py │ ├── tag_handler.py │ ├── things_server.py │ ├── url_scheme.py │ └── utils.py ├── things_fast_server.py └── things_server.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # poetry 98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 102 | #poetry.lock 103 | 104 | # pdm 105 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 106 | #pdm.lock 107 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 108 | # in version control. 109 | # https://pdm.fming.dev/#use-with-ide 110 | .pdm.toml 111 | 112 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 113 | __pypackages__/ 114 | 115 | # Celery stuff 116 | celerybeat-schedule 117 | celerybeat.pid 118 | 119 | # SageMath parsed files 120 | *.sage.py 121 | 122 | # Environments 123 | .env 124 | .venv 125 | env/ 126 | venv/ 127 | ENV/ 128 | env.bak/ 129 | venv.bak/ 130 | 131 | # Spyder project settings 132 | .spyderproject 133 | .spyproject 134 | 135 | # Rope project settings 136 | .ropeproject 137 | 138 | # mkdocs documentation 139 | /site 140 | 141 | # mypy 142 | .mypy_cache/ 143 | .dmypy.json 144 | dmypy.json 145 | 146 | # Pyre type checker 147 | .pyre/ 148 | 149 | # pytype static type analyzer 150 | .pytype/ 151 | 152 | # Cython debug symbols 153 | cython_debug/ 154 | 155 | # PyCharm 156 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 157 | # be added to the global gitignore or merged into this project gitignore. For a PyCharm 158 | # project, it is also recommended to add the following: 159 | # -ideaconfiguration 160 | # -configuration 161 | # -workspace 162 | .idea/ 163 | 164 | # VSCode 165 | .vscode/ 166 | 167 | # macOS 168 | .DS_Store 169 | .AppleDouble 170 | .LSOverride 171 | 172 | # Test files and temporary development files 173 | test_*.py 174 | test_*.sh 175 | minimal_test.py 176 | quick_test.py 177 | run_simple_server.py 178 | set_token_directly.py 179 | 180 | # MCP development 181 | *.log 182 | logs/ -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to Things 3 Enhanced MCP will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## [1.0.0] - 2025-05-30 9 | 10 | ### Added 11 | - 🚀 **FastMCP Implementation**: Complete rewrite using FastMCP pattern for better maintainability 12 | - 🔄 **Reliability Features**: 13 | - Circuit breaker pattern to prevent cascading failures 14 | - Exponential backoff retry logic for transient failures 15 | - Dead letter queue for failed operations 16 | - ⚡ **Performance Optimizations**: 17 | - Intelligent caching system with TTL management 18 | - Rate limiting to prevent overwhelming Things app 19 | - Automatic cache invalidation on data modifications 20 | - 🍎 **AppleScript Bridge**: Fallback mechanism when URL schemes fail 21 | - 📊 **Enhanced Monitoring**: 22 | - Structured JSON logging 23 | - Performance metrics and statistics 24 | - Comprehensive error tracking 25 | - Debug-friendly output 26 | - 🛡️ **Error Handling**: Comprehensive exception management and recovery 27 | - 🧪 **Test Suite**: Extensive tests for reliability 28 | - 📦 **Smithery Support**: Full configuration for Smithery registry deployment 29 | - 📝 **Documentation**: Enhanced README with detailed setup and troubleshooting guides 30 | 31 | ### Changed 32 | - Rebranded to "Things 3 Enhanced MCP" for clear differentiation 33 | - Updated package name to `things3-enhanced-mcp` 34 | - Improved configuration token handling 35 | - Enhanced URL scheme operations with better error recovery 36 | 37 | ### Fixed 38 | - Token configuration import issues 39 | - URL scheme reliability problems 40 | - Various edge cases in task/project operations 41 | 42 | ### Attribution 43 | Based on the original [things-mcp](https://github.com/hald/things-mcp) by Harald Lindstrøm 44 | 45 | [1.0.0]: https://github.com/excelsier/things-fastmcp/releases/tag/v1.0.0 -------------------------------------------------------------------------------- /IMPLEMENTATION_SUMMARY.md: -------------------------------------------------------------------------------- 1 | # Things MCP Implementation Summary 2 | 3 | ## What We've Accomplished 4 | 5 | ### 1. Authentication Token Support ✅ 6 | - Configured authentication token (stored in `~/.things-mcp/config.json`) 7 | - Token is now included in all URL scheme operations 8 | - Added environment variable support (`THINGS_AUTH_TOKEN`) 9 | - Updated documentation with authentication setup instructions 10 | 11 | ### 2. Project Operations ✅ 12 | The MCP server now supports complete project management: 13 | 14 | #### Creating Projects 15 | - `add-project` - Create projects with: 16 | - Title and notes 17 | - Tags (automatically creates missing tags) 18 | - Deadline and scheduling 19 | - Area assignment 20 | - Initial todos 21 | 22 | #### Updating Projects 23 | - `update-project` - Update existing projects: 24 | - Modify title, notes, deadline 25 | - Add/change tags 26 | - Mark as completed 27 | - Mark as canceled 28 | 29 | #### Deleting Projects ✅ 30 | - `delete-project` - Moves projects to trash (cancels them) 31 | 32 | ### 3. Tag Handling ✅ 33 | - Automatic tag creation when tags don't exist 34 | - Tag handler module (`tag_handler.py`) ensures tags exist before use 35 | - Works for both todos and projects 36 | 37 | ### 4. Todo Operations ✅ 38 | - Full CRUD operations for todos 39 | - `delete-todo` added for consistency with project operations 40 | 41 | ### 5. Testing ✅ 42 | Created comprehensive test scripts: 43 | - `test_auth_token.py` - Authentication token testing 44 | - `test_auth_simple.sh` - Basic auth verification 45 | - `test_project_operations.sh` - Full project lifecycle testing 46 | - `test_tags.sh` - Tag operations testing 47 | - `test_existing_tags.sh` - Testing with pre-existing tags 48 | - `test_tag_creation.sh` - Automatic tag creation testing 49 | 50 | ### 6. Documentation Updates ✅ 51 | - Added authentication token setup instructions 52 | - Documented all project operations 53 | - Added important limitations section 54 | - Updated tool parameters documentation 55 | 56 | ## Current Status 57 | 58 | ### Working Features ✅ 59 | 1. **Authentication**: Token properly configured and working 60 | 2. **Project Assignment**: Todos are correctly assigned to projects 61 | 3. **Tag Creation**: Tags are automatically created if they don't exist 62 | 4. **Project Management**: Full lifecycle (create, update, complete, delete) 63 | 5. **Area Assignment**: Works correctly 64 | 65 | ### Known Limitations 66 | 1. **Tags**: Must exist before use (but server auto-creates them) 67 | 2. **Python Timeouts**: Some Python scripts timeout but functionality works 68 | 3. **URL Scheme**: Some operations only work via URL scheme, not AppleScript 69 | 70 | ## Configuration Details 71 | 72 | ### Authentication Token 73 | - Token: `2H2TYfJbSfWaEYntMJkreg` (configured for testing) 74 | - Location: `~/.things-mcp/config.json` 75 | - Environment variable: `THINGS_AUTH_TOKEN` 76 | 77 | ### File Structure 78 | ``` 79 | things-fastmcp/ 80 | ├── src/things_mcp/ 81 | │ ├── simple_server.py # Main MCP server 82 | │ ├── simple_url_scheme.py # URL scheme implementation 83 | │ ├── tag_handler.py # Tag management 84 | │ ├── config.py # Configuration management 85 | │ └── ... 86 | ├── configure_token.py # Token configuration script 87 | └── test_*.sh # Various test scripts 88 | ``` 89 | 90 | ## Next Steps (Optional) 91 | 92 | 1. **Publish to PyPI**: Update version and publish the enhanced server 93 | 2. **Advanced Features**: 94 | - Batch operations 95 | - More sophisticated tag management 96 | - Project templates 97 | 3. **Error Handling**: More detailed error messages for specific failures 98 | 4. **Performance**: Optimize tag creation for bulk operations -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Yaroslav Krempovych 4 | 5 | Based on the original "things-mcp" project by Harald Lindstrøm 6 | Original repository: https://github.com/hald/things-mcp 7 | 8 | Permission is hereby granted, free of charge, to any person obtaining a copy 9 | of this software and associated documentation files (the "Software"), to deal 10 | in the Software without restriction, including without limitation the rights 11 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 12 | copies of the Software, and to permit persons to whom the Software is 13 | furnished to do so, subject to the following conditions: 14 | 15 | The above copyright notice and this permission notice shall be included in all 16 | copies or substantial portions of the Software. 17 | 18 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 19 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 20 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 21 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 22 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 23 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 24 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Things MCP Server 2 | 3 | This [Model Context Protocol (MCP)](https://modelcontextprotocol.io/introduction) server lets you use Claude Desktop to interact with your task management data in [Things app](https://culturedcode.com/things). You can ask Claude to create tasks, analyze projects, help manage priorities, and more. 4 | 5 | This server leverages the [Things.py](https://github.com/thingsapi/things.py) library and the [Things URL Scheme](https://culturedcode.com/things/help/url-scheme/), with additional reliability features including: 6 | 7 | - **Robust error handling** with exponential backoff and retry mechanisms 8 | - **Circuit breaker pattern** to prevent cascading failures 9 | - **Dead letter queue** for failed operations 10 | - **Intelligent caching** for improved performance 11 | - **Comprehensive logging** with structured JSON output 12 | - **AppleScript bridge** for operations that fail with URL schemes 13 | - **Rate limiting** to prevent overwhelming the Things app 14 | - **Extensive test suite** for reliability 15 | 16 | ## Why Things MCP? 17 | 18 | This MCP server unlocks the power of AI for your task management: 19 | 20 | - **Natural Language Task Creation**: Ask Claude to create tasks with all details in natural language 21 | - **Smart Task Analysis**: Get insights into your projects and productivity patterns 22 | - **GTD & Productivity Workflows**: Let Claude help you implement productivity systems 23 | - **Seamless Integration**: Works directly with your existing Things 3 data 24 | 25 | ## Features 26 | 27 | - Access to all major Things lists (Inbox, Today, Upcoming, etc.) 28 | - Project and area management 29 | - Tag operations 30 | - Advanced search capabilities 31 | - Recent items tracking 32 | - Detailed item information including checklists 33 | - Support for nested data (projects within areas, todos within projects) 34 | 35 | ## Installation Options 36 | 37 | There are multiple ways to install and use the Things MCP server: 38 | 39 | ### Option 1: Install from PyPI (Recommended) 40 | 41 | #### Prerequisites 42 | * Python 3.12+ 43 | * Claude Desktop 44 | * Things 3 ("Enable Things URLs" must be turned on in Settings -> General) 45 | * Things Authentication Token (required for URL scheme operations) 46 | 47 | #### Installation 48 | 49 | ```bash 50 | pip install things-mcp 51 | ``` 52 | 53 | Or using uv (recommended): 54 | 55 | ```bash 56 | uv pip install things-mcp 57 | ``` 58 | 59 | #### Running 60 | 61 | After installation, you can run the server directly: 62 | 63 | ```bash 64 | things-mcp 65 | ``` 66 | 67 | ### Option 2: Manual Installation 68 | 69 | #### Prerequisites 70 | * Python 3.12+ 71 | * Claude Desktop 72 | * Things 3 ("Enable Things URLs" must be turned on in Settings -> General) 73 | 74 | #### Step 1: Install uv 75 | Install uv if you haven't already: 76 | ```bash 77 | curl -LsSf https://astral.sh/uv/install.sh | sh 78 | ``` 79 | Restart your terminal afterwards. 80 | 81 | #### Step 2: Clone this repository 82 | ```bash 83 | git clone https://github.com/hald/things-mcp 84 | cd things-mcp 85 | ``` 86 | 87 | #### Step 3: Set up Python environment and dependencies 88 | ```bash 89 | uv venv 90 | uv pip install -r pyproject.toml 91 | ``` 92 | 93 | ### Step 4: Configure Things authentication token 94 | Run the configuration tool to set up your Things authentication token: 95 | ```bash 96 | python configure_token.py 97 | ``` 98 | This will guide you through the process of configuring your Things authentication token, which is required for the MCP server to interact with your Things app. 99 | 100 | ### Step 5: Configure Claude Desktop 101 | Edit the Claude Desktop configuration file: 102 | ```bash 103 | code ~/Library/Application\ Support/Claude/claude_desktop_config.json 104 | ``` 105 | 106 | Add the Things server to the mcpServers key in the configuration file (be sure to update the path to the folder where you installed these files): 107 | ```json 108 | { 109 | "mcpServers": { 110 | "things": { 111 | "command": "uv", 112 | "args": [ 113 | "--directory", 114 | "/ABSOLUTE/PATH/TO/PARENT/FOLDER/things-mcp", 115 | "run", 116 | "things_server.py" 117 | ] 118 | } 119 | } 120 | } 121 | ``` 122 | 123 | ### Step 6: Configure Authentication Token 124 | The Things URL scheme requires an authentication token. You can find it in Things → Settings → General. 125 | 126 | Option 1: Set via configuration script 127 | ```bash 128 | python configure_token.py 129 | ``` 130 | 131 | Option 2: Set via environment variable 132 | ```bash 133 | export THINGS_AUTH_TOKEN="your-token-here" 134 | ``` 135 | 136 | Option 3: Manually create config file 137 | ```bash 138 | mkdir -p ~/.things-mcp 139 | echo '{"things_auth_token": "your-token-here"}' > ~/.things-mcp/config.json 140 | ``` 141 | 142 | ### Step 7: Restart Claude Desktop 143 | Restart the Claude Desktop app to apply the changes. 144 | 145 | ### Sample Usage with Claude Desktop 146 | * "What's on my todo list today?" 147 | * "Create a todo to pack for my beach vacation next week, include a packling checklist." 148 | * "Evaluate my current todos using the Eisenhower matrix." 149 | * "Help me conduct a GTD-style weekly review using Things." 150 | 151 | #### Tips 152 | * Create a project in Claude with custom instructions that explains how you use Things and organize areas, projects, tags, etc. Tell Claude what information you want included when it creates a new task (eg asking it to include relevant details in the task description might be helpful). 153 | * Try adding another MCP server that gives Claude access to your calendar. This will let you ask Claude to block time on your calendar for specific tasks, create todos from upcoming calendar events (eg prep for a meeting), etc. 154 | 155 | 156 | ### Available Tools 157 | 158 | #### List Views 159 | - `get-inbox` - Get todos from Inbox 160 | - `get-today` - Get todos due today 161 | - `get-upcoming` - Get upcoming todos 162 | - `get-anytime` - Get todos from Anytime list 163 | - `get-someday` - Get todos from Someday list 164 | - `get-logbook` - Get completed todos 165 | - `get-trash` - Get trashed todos 166 | 167 | #### Basic Operations 168 | - `get-todos` - Get todos, optionally filtered by project 169 | - `get-projects` - Get all projects 170 | - `get-areas` - Get all areas 171 | 172 | #### Tag Operations 173 | - `get-tags` - Get all tags 174 | - `get-tagged-items` - Get items with a specific tag 175 | 176 | #### Search Operations 177 | - `search-todos` - Simple search by title/notes 178 | - `search-advanced` - Advanced search with multiple filters 179 | 180 | #### Time-based Operations 181 | - `get-recent` - Get recently created items 182 | 183 | #### Modification Operations 184 | - `add-todo` - Create a new todo with full parameter support 185 | - `add-project` - Create a new project with tags and todos 186 | - `update-todo` - Update an existing todo 187 | - `update-project` - Update an existing project 188 | - `delete-todo` - Delete a todo (moves to trash) 189 | - `delete-project` - Delete a project (moves to trash) 190 | - `show-item` - Show a specific item or list in Things 191 | - `search-items` - Search for items in Things 192 | 193 | ## Tool Parameters 194 | 195 | ### get-todos 196 | - `project_uuid` (optional) - Filter todos by project 197 | - `include_items` (optional, default: true) - Include checklist items 198 | 199 | ### get-projects / get-areas / get-tags 200 | - `include_items` (optional, default: false) - Include contained items 201 | 202 | ### search-advanced 203 | - `status` - Filter by status (incomplete/completed/canceled) 204 | - `start_date` - Filter by start date (YYYY-MM-DD) 205 | - `deadline` - Filter by deadline (YYYY-MM-DD) 206 | - `tag` - Filter by tag 207 | - `area` - Filter by area UUID 208 | - `type` - Filter by item type (to-do/project/heading) 209 | 210 | ### get-recent 211 | - `period` - Time period (e.g., '3d', '1w', '2m', '1y') 212 | 213 | ### add-todo 214 | - `title` - Title of the todo 215 | - `notes` (optional) - Notes for the todo 216 | - `when` (optional) - When to schedule the todo (today, tomorrow, evening, anytime, someday, or YYYY-MM-DD) 217 | - `deadline` (optional) - Deadline for the todo (YYYY-MM-DD) 218 | - `tags` (optional) - Tags to apply to the todo 219 | - `list_title` or `list_id` (optional) - Title or ID of project/area to add to 220 | - `heading` (optional) - Heading to add under 221 | - `checklist_items` (optional) - Checklist items to add 222 | 223 | ### update-todo 224 | - `id` - ID of the todo to update 225 | - `title` (optional) - New title 226 | - `notes` (optional) - New notes 227 | - `when` (optional) - New schedule 228 | - `deadline` (optional) - New deadline 229 | - `tags` (optional) - New tags 230 | - `completed` (optional) - Mark as completed 231 | - `canceled` (optional) - Mark as canceled 232 | 233 | ### add-project 234 | - `title` - Title of the project 235 | - `notes` (optional) - Notes for the project 236 | - `when` (optional) - When to schedule the project 237 | - `deadline` (optional) - Deadline for the project 238 | - `tags` (optional) - Tags to apply to the project 239 | - `area_title` or `area_id` (optional) - Title or ID of area to add to 240 | - `todos` (optional) - Initial todos to create in the project 241 | 242 | ### update-project 243 | - `id` - ID of the project to update 244 | - `title` (optional) - New title 245 | - `notes` (optional) - New notes 246 | - `when` (optional) - New schedule 247 | - `deadline` (optional) - New deadline 248 | - `tags` (optional) - New tags 249 | - `completed` (optional) - Mark as completed 250 | - `canceled` (optional) - Mark as canceled 251 | 252 | ### delete-todo 253 | - `id` - ID of the todo to delete (moves to trash) 254 | 255 | ### delete-project 256 | - `id` - ID of the project to delete (moves to trash) 257 | 258 | ### show-item 259 | - `id` - ID of item to show, or one of: inbox, today, upcoming, anytime, someday, logbook 260 | - `query` (optional) - Optional query to filter by 261 | - `filter_tags` (optional) - Optional tags to filter by 262 | 263 | ## Important Limitations 264 | 265 | ### Tags 266 | - Tags must exist in Things before they can be applied to todos or projects 267 | - The MCP server will automatically create missing tags when you try to use them 268 | - If tag creation fails, the todo/project will still be created but without tags 269 | 270 | ### Authentication Token 271 | - Required for all URL scheme operations (create, update, delete) 272 | - Without a token, Things will prompt for authentication on each operation 273 | 274 | ## Authentication Token Configuration 275 | 276 | The Things MCP server requires an authentication token to interact with the Things app. This token is used to authorize URL scheme commands. 277 | 278 | ### How to get your Things authentication token 279 | 280 | 1. Open Things app on your Mac 281 | 2. Go to Things → Preferences (⌘,) 282 | 3. Select the General tab 283 | 4. Make sure "Enable Things URLs" is checked 284 | 5. Look for the authentication token displayed in the preferences window 285 | 286 | ### Configuring the token 287 | 288 | Run the included configuration tool to set up your token: 289 | 290 | ```bash 291 | python configure_token.py 292 | ``` 293 | 294 | This interactive script will prompt you for your token and save it securely in your local configuration. 295 | 296 | ## Development 297 | 298 | This project uses `pyproject.toml` to manage dependencies and build configuration. It's built using the [Model Context Protocol](https://modelcontextprotocol.io), which allows Claude to securely access tools and data. 299 | 300 | ### Implementation Options 301 | 302 | This project provides two different implementation approaches: 303 | 304 | 1. **Standard MCP Server** (`things_server.py`) - The original implementation that uses the basic MCP server pattern. 305 | 306 | 2. **FastMCP Server** (`things_fast_server.py`) - A modern implementation using the FastMCP pattern for cleaner, more maintainable code with decorator-based tool registration. 307 | 308 | ### Development Workflow 309 | 310 | #### Setting up a development environment 311 | 312 | ```bash 313 | # Clone the repository 314 | git clone https://github.com/hald/things-mcp 315 | cd things-mcp 316 | 317 | # Set up a virtual environment with development dependencies 318 | uv venv 319 | uv pip install -e ".[dev]" # Install in development mode with extra dependencies 320 | ``` 321 | 322 | #### Testing changes during development 323 | 324 | Use the MCP development server to test changes: 325 | 326 | ```bash 327 | # Test the FastMCP implementation 328 | mcp dev things_fast_server.py 329 | 330 | # Or test the traditional implementation 331 | mcp dev things_server.py 332 | ``` 333 | 334 | #### Building the package for PyPI 335 | 336 | ```bash 337 | python -m build 338 | ``` 339 | 340 | #### Publishing to PyPI 341 | 342 | ```bash 343 | twine upload dist/* 344 | ``` 345 | 346 | Requires Python 3.12+. 347 | 348 | ## Reliability Features 349 | 350 | ### Error Handling & Recovery 351 | - **Retry Logic**: Automatic retries with exponential backoff for transient failures 352 | - **Circuit Breaker**: Prevents repeated failures from overwhelming the system 353 | - **Dead Letter Queue**: Failed operations are stored for later retry or analysis 354 | - **AppleScript Fallback**: When URL scheme operations fail, falls back to direct AppleScript 355 | 356 | ### Performance Optimization 357 | - **Smart Caching**: Frequently accessed data is cached with appropriate TTLs 358 | - **Rate Limiting**: Prevents overwhelming Things app with too many requests 359 | - **Cache Invalidation**: Automatic cache clearing when data is modified 360 | 361 | ### Monitoring & Debugging 362 | - **Structured Logging**: JSON-formatted logs for better analysis 363 | - **Operation Tracking**: Each operation is logged with timing and status 364 | - **Cache Statistics**: Monitor cache performance with `get-cache-stats` tool 365 | - **Log Locations**: 366 | - Main logs: `~/.things-mcp/logs/things_mcp.log` 367 | - Structured logs: `~/.things-mcp/logs/things_mcp_structured.json` 368 | - Error logs: `~/.things-mcp/logs/things_mcp_errors.log` 369 | 370 | ## Troubleshooting 371 | 372 | The server includes error handling for: 373 | - Invalid UUIDs 374 | - Missing required parameters 375 | - Things database access errors 376 | - Data formatting errors 377 | - Authentication token issues 378 | - Network timeouts 379 | - AppleScript execution failures 380 | 381 | ### Common Issues 382 | 383 | 1. **Missing or invalid token**: Run `python configure_token.py` to set up your token 384 | 2. **Things app not running**: The server will attempt to launch Things automatically 385 | 3. **URL scheme not enabled**: Check that "Enable Things URLs" is enabled in Things → Preferences → General 386 | 4. **Operations failing**: Check the circuit breaker status and dead letter queue 387 | 5. **Performance issues**: Monitor cache statistics with the `get-cache-stats` tool 388 | 389 | ### Checking Logs 390 | 391 | All errors are logged and returned with descriptive messages. To review the MCP logs: 392 | 393 | ```bash 394 | # Follow main logs in real-time 395 | tail -f ~/.things-mcp/logs/things_mcp.log 396 | 397 | # Check error logs 398 | tail -f ~/.things-mcp/logs/things_mcp_errors.log 399 | 400 | # View structured logs for analysis 401 | cat ~/.things-mcp/logs/things_mcp_structured.json | jq 402 | 403 | # Claude Desktop MCP logs 404 | tail -n 20 -f ~/Library/Logs/Claude/mcp*.log 405 | ``` 406 | 407 | ### Advanced Debugging 408 | 409 | 1. **Check Dead Letter Queue**: Failed operations are stored in `things_dlq.json` 410 | 2. **Monitor Circuit Breaker**: Look for "Circuit breaker" messages in logs 411 | 3. **Cache Performance**: Use `get-cache-stats` tool to check hit rates 412 | 4. **Enable Debug Logging**: Set console level to DEBUG in `logging_config.py` 413 | -------------------------------------------------------------------------------- /RELEASE_NOTES.md: -------------------------------------------------------------------------------- 1 | # Release Notes - Things MCP v1.0.0 2 | 3 | ## Major Features 4 | 5 | ### FastMCP Implementation 6 | - Added modern FastMCP pattern with decorator-based tool registration 7 | - Cleaner and more maintainable code structure 8 | - Better error handling and validation 9 | 10 | ### New Tool Additions 11 | - **Task Management** 12 | - `add-todo` - Create tasks with rich metadata and checklist items 13 | - `update-todo` - Update existing tasks with comprehensive options 14 | 15 | - **Project Management** 16 | - `add-project` - Create projects with initial todos and metadata 17 | - `update-project` - Update existing projects with full control 18 | 19 | - **Enhanced Search** 20 | - `search-advanced` - Multi-criteria search with filtering 21 | - `search-items` - Global search across all Things items 22 | - `search-todos` - Targeted todo search 23 | 24 | - **Navigation & Time-based Features** 25 | - `show-item` - Open specific items directly in Things 26 | - `get-recent` - View recently created items 27 | - `get-tagged-items` - List items with specific tags 28 | 29 | ### Infrastructure Improvements 30 | - Proper package structure for PyPI distribution 31 | - GitHub Actions CI workflow for automated testing 32 | - Comprehensive test suite for all new functionality 33 | - Enhanced error handling and validation 34 | 35 | ### Documentation 36 | - Updated README with comprehensive tool documentation 37 | - Added CONTRIBUTING.md for contributor guidelines 38 | - Added CHANGELOG.md for version history tracking 39 | 40 | ## Breaking Changes 41 | - None. All original functionality is preserved for backward compatibility. 42 | 43 | ## Bugfixes 44 | - Improved error handling for URL scheme interactions 45 | - Better handling of special characters in task and project names 46 | - Fixed issues with tag management 47 | 48 | ## Installation 49 | ```bash 50 | pip install things-mcp 51 | ``` 52 | 53 | ## Usage 54 | ```bash 55 | # Run the FastMCP implementation 56 | mcp dev things_fast_server.py 57 | 58 | # Or install for persistent use 59 | mcp install things_fast_server.py 60 | ``` 61 | -------------------------------------------------------------------------------- /configure_token.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | Configuration tool for setting up the Things authentication token. 4 | """ 5 | import sys 6 | import logging 7 | from src.things_mcp import config 8 | 9 | # Configure logging 10 | logging.basicConfig(level=logging.INFO, 11 | format='%(asctime)s - %(name)s - %(levelname)s - %(message)s') 12 | logger = logging.getLogger(__name__) 13 | 14 | def setup_auth_token(): 15 | """Interactive setup for the Things authentication token.""" 16 | print("\n=== Things MCP Configuration ===") 17 | print("\nThis tool helps you set up the authentication token required for Things URL commands.") 18 | print("You can find your token in Things → Settings → General.") 19 | print("\nThe token is saved in your configuration file and used for all URL scheme operations.") 20 | 21 | # Display current token if any 22 | current_token = config.get_things_auth_token() 23 | if current_token: 24 | print(f"\nCurrent token: {current_token}") 25 | else: 26 | print("\nNo token currently configured.") 27 | 28 | # Prompt for new token 29 | print("\nEnter your Things authentication token (leave empty to keep current token):") 30 | new_token = input("> ").strip() 31 | 32 | if not new_token: 33 | if current_token: 34 | print("\nKeeping current token.") 35 | return True 36 | else: 37 | print("\nError: No token provided and no existing token found.") 38 | return False 39 | 40 | # Save the new token 41 | success = config.set_things_auth_token(new_token) 42 | 43 | if success: 44 | print(f"\nAuthentication token saved successfully to {config.CONFIG_FILE}") 45 | return True 46 | else: 47 | print("\nError: Failed to save authentication token.") 48 | return False 49 | 50 | if __name__ == "__main__": 51 | print("Things MCP Token Configuration Tool") 52 | setup_auth_token() 53 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "things3-enhanced-mcp" 3 | version = "1.0.0" 4 | description = "Enhanced FastMCP server for Things 3 app integration with Claude Desktop and Windsurf - featuring reliability improvements, caching, and AppleScript bridge" 5 | readme = "README.md" 6 | requires-python = ">=3.12" 7 | license = {text = "MIT License"} 8 | authors = [ 9 | {name = "Yaroslav Krempovych", email = "51231325+excelsior@users.noreply.github.com"}, 10 | {name = "Harald Lindstrøm", email = "hald@users.noreply.github.com"} 11 | ] 12 | keywords = ["mcp", "anthropic", "claude", "things3", "things", "task-management", "productivity", "fastmcp", "macos", "apple"] 13 | classifiers = [ 14 | "Development Status :: 4 - Beta", 15 | "Intended Audience :: End Users/Desktop", 16 | "License :: OSI Approved :: MIT License", 17 | "Programming Language :: Python :: 3.12", 18 | "Topic :: Utilities", 19 | "Topic :: Office/Business :: Scheduling", 20 | "Topic :: Software Development :: Libraries :: Python Modules", 21 | "Operating System :: MacOS" 22 | ] 23 | dependencies = [ 24 | "httpx>=0.28.1", 25 | "mcp[cli]>=1.2.0", 26 | "things-py>=0.0.15", 27 | ] 28 | 29 | [project.scripts] 30 | things3-enhanced-mcp = "src.things_mcp.fast_server:run_things_mcp_server" 31 | 32 | [project.urls] 33 | "Homepage" = "https://github.com/excelsier/things-fastmcp" 34 | "Bug Tracker" = "https://github.com/excelsier/things-fastmcp/issues" 35 | "Documentation" = "https://github.com/excelsier/things-fastmcp#readme" 36 | 37 | [build-system] 38 | requires = ["hatchling"] 39 | build-backend = "hatchling.build" 40 | 41 | [tool.hatch.build.targets.wheel] 42 | packages = ["src/things_mcp"] 43 | 44 | [tool.hatch.build.targets.sdist] 45 | include = [ 46 | "src/things_mcp", 47 | "README.md", 48 | "LICENSE", 49 | "pyproject.toml", 50 | "things_fast_server.py", 51 | "things_server.py" 52 | ] 53 | 54 | [tool.hatch.envs.default] 55 | dependencies = [ 56 | "pytest", 57 | "pytest-cov", 58 | "ruff", 59 | "build", 60 | "twine" 61 | ] 62 | 63 | [project.optional-dependencies] 64 | dev = [ 65 | "pytest>=7.0.0", 66 | "pytest-cov>=4.0.0", 67 | "ruff>=0.1.0", 68 | "build>=0.10.0", 69 | "twine>=4.0.0" 70 | ] 71 | 72 | [tool.hatch.envs.default.scripts] 73 | test = "pytest {args:tests}" 74 | lint = "ruff check ." 75 | format = "ruff format ." 76 | build = "python -m build" 77 | publish = "twine upload dist/*" 78 | clean = "rm -rf dist build *.egg-info" 79 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | testpaths = tests 3 | python_files = test_*.py 4 | python_classes = Test* 5 | python_functions = test_* 6 | addopts = -v --tb=short --strict-markers 7 | markers = 8 | slow: marks tests as slow (deselect with '-m "not slow"') 9 | integration: marks tests as integration tests 10 | unit: marks tests as unit tests -------------------------------------------------------------------------------- /smithery.yaml: -------------------------------------------------------------------------------- 1 | name: things3-enhanced-mcp 2 | version: 1.0.0 3 | description: "Enhanced FastMCP server for Things 3 app integration with Claude Desktop and Windsurf - featuring reliability improvements, caching, and AppleScript bridge" 4 | 5 | # Smithery configuration 6 | registry: 7 | displayName: "Things 3 Enhanced MCP" 8 | shortDescription: "Advanced Things 3 integration with enterprise reliability features" 9 | longDescription: | 10 | Enhanced Model Context Protocol server for seamless Things 3 app integration with AI assistants. 11 | Features production-ready reliability with circuit breaker patterns, intelligent caching, AppleScript 12 | bridge, and comprehensive error handling. Perfect for macOS users wanting robust task management 13 | automation with Claude Desktop or Windsurf. 14 | 15 | tags: 16 | - task-management 17 | - productivity 18 | - things3 19 | - macos 20 | - apple 21 | - fastmcp 22 | - reliability 23 | - gtd 24 | 25 | category: "Task Management" 26 | 27 | # Requirements 28 | requirements: 29 | os: ["macos"] 30 | platform: ["local"] 31 | 32 | # Documentation 33 | homepage: "https://github.com/excelsier/things-fastmcp" 34 | repository: "https://github.com/excelsier/things-fastmcp" 35 | documentation: "https://github.com/excelsier/things-fastmcp#readme" 36 | 37 | # Installation 38 | installation: 39 | type: "pip" 40 | package: "things3-enhanced-mcp" 41 | command: "things3-enhanced-mcp" 42 | 43 | # Configuration requirements 44 | configuration: 45 | required: 46 | - name: "THINGS_AUTH_TOKEN" 47 | description: "Authentication token from Things 3 Preferences → General" 48 | type: "string" 49 | secret: true 50 | optional: 51 | - name: "THINGS_MCP_LOG_LEVEL" 52 | description: "Logging level (DEBUG, INFO, WARNING, ERROR)" 53 | type: "string" 54 | default: "INFO" 55 | 56 | # Build configuration 57 | build: 58 | type: "python" 59 | python_version: "3.12" 60 | 61 | # Entry point 62 | entry_point: "src.things_mcp.fast_server:run_things_mcp_server" 63 | 64 | # Dependencies from pyproject.toml 65 | dependencies: 66 | - "httpx>=0.28.1" 67 | - "mcp[cli]>=1.2.0" 68 | - "things-py>=0.0.15" 69 | 70 | # Deployment settings 71 | deployment: 72 | type: "local" # This runs locally, not on Smithery's servers 73 | 74 | # Health checks 75 | health: 76 | startup_timeout: 30 77 | readiness_probe: 78 | path: "/health" 79 | timeout: 5 80 | 81 | # Features/Tools provided by this MCP 82 | tools: 83 | - name: "get-inbox" 84 | description: "Get todos from Things 3 Inbox" 85 | - name: "get-today" 86 | description: "Get todos due today" 87 | - name: "get-upcoming" 88 | description: "Get upcoming todos" 89 | - name: "get-anytime" 90 | description: "Get todos from Anytime list" 91 | - name: "get-someday" 92 | description: "Get todos from Someday list" 93 | - name: "add-todo" 94 | description: "Create a new todo with full parameter support" 95 | - name: "add-project" 96 | description: "Create a new project with tags and todos" 97 | - name: "update-todo" 98 | description: "Update an existing todo" 99 | - name: "search-todos" 100 | description: "Search for todos by title/notes" 101 | - name: "get-tags" 102 | description: "Get all available tags" -------------------------------------------------------------------------------- /src/things_mcp/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Things MCP Server - Model Context Protocol server for Things task management app. 3 | """ 4 | 5 | __version__ = "0.1.1" 6 | -------------------------------------------------------------------------------- /src/things_mcp/applescript_bridge.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import subprocess 3 | import logging 4 | from typing import Optional, List, Dict, Any, Union 5 | 6 | logger = logging.getLogger(__name__) 7 | 8 | def run_applescript(script: str) -> Union[str, bool]: 9 | """Run an AppleScript command and return the result. 10 | 11 | Args: 12 | script: The AppleScript code to execute 13 | 14 | Returns: 15 | The result of the AppleScript execution, or False if it failed 16 | """ 17 | try: 18 | result = subprocess.run(['osascript', '-e', script], 19 | capture_output=True, text=True) 20 | 21 | if result.returncode != 0: 22 | logger.error(f"AppleScript error: {result.stderr}") 23 | return False 24 | 25 | return result.stdout.strip() 26 | except Exception as e: 27 | logger.error(f"Error running AppleScript: {str(e)}") 28 | return False 29 | 30 | def add_todo_direct(title: str, notes: Optional[str] = None, when: Optional[str] = None, 31 | tags: Optional[List[str]] = None, list_title: Optional[str] = None) -> str: 32 | """Add a todo to Things directly using AppleScript. 33 | 34 | This bypasses URL schemes entirely to avoid encoding issues. 35 | 36 | Args: 37 | title: Title of the todo 38 | notes: Notes for the todo 39 | when: When to schedule the todo (today, tomorrow, evening, anytime, someday) 40 | tags: Tags to apply to the todo 41 | list_title: Name of project/area to add to 42 | 43 | Returns: 44 | ID of the created todo if successful, False otherwise 45 | """ 46 | # Build the AppleScript command 47 | script_parts = ['tell application "Things3"'] 48 | 49 | # Create the todo with properties 50 | properties = [] 51 | properties.append(f'name:"{escape_applescript_string(title)}"') 52 | 53 | if notes: 54 | properties.append(f'notes:"{escape_applescript_string(notes)}"') 55 | 56 | # Create with properties in the right way 57 | script_parts.append(f'set newTodo to make new to do with properties {{{", ".join(properties)}}}') 58 | 59 | # Add scheduling 60 | if when: 61 | when_mapping = { 62 | 'today': '', # Default is today, no need to set 63 | 'tomorrow': 'set activation date of newTodo to ((current date) + 1 * days)', 64 | 'evening': '', # Default is today, no need to set 65 | 'anytime': '', # Default 66 | 'someday': 'set status of newTodo to someday' 67 | } 68 | 69 | if when in when_mapping: 70 | if when_mapping[when]: 71 | script_parts.append(when_mapping[when]) 72 | else: 73 | # For date handling, it's safest to just log it and not try to set it 74 | # This avoids AppleScript date formatting issues 75 | logger.warning(f"Custom date format '{when}' not supported, defaulting to today") 76 | 77 | # Add tags if provided 78 | if tags and len(tags) > 0: 79 | for tag in tags: 80 | script_parts.append(f'tell newTodo to make new tag with properties {{name:"{escape_applescript_string(tag)}"}}') 81 | 82 | # Add to a specific project/area if specified 83 | if list_title: 84 | script_parts.append(f'set project_name to "{escape_applescript_string(list_title)}"') 85 | script_parts.append('try') 86 | script_parts.append(' set target_project to first project whose name is project_name') 87 | script_parts.append(' set project of newTodo to target_project') 88 | script_parts.append('on error') 89 | script_parts.append(' -- Project not found, try area') 90 | script_parts.append(' try') 91 | script_parts.append(' set target_area to first area whose name is project_name') 92 | script_parts.append(' set area of newTodo to target_area') 93 | script_parts.append(' on error') 94 | script_parts.append(' -- Neither project nor area found, todo will remain in inbox') 95 | script_parts.append(' end try') 96 | script_parts.append('end try') 97 | 98 | # Get the ID of the created todo 99 | script_parts.append('return id of newTodo') 100 | 101 | # Close the tell block 102 | script_parts.append('end tell') 103 | 104 | # Execute the script 105 | script = '\n'.join(script_parts) 106 | logger.debug(f"Executing AppleScript: {script}") 107 | 108 | result = run_applescript(script) 109 | if result: 110 | logger.info(f"Successfully created todo with ID: {result}") 111 | return result 112 | else: 113 | logger.error("Failed to create todo") 114 | return False 115 | 116 | def escape_applescript_string(text: str) -> str: 117 | """Escape special characters in an AppleScript string. 118 | 119 | Args: 120 | text: The string to escape 121 | 122 | Returns: 123 | The escaped string 124 | """ 125 | if not text: 126 | return "" 127 | 128 | # Replace any "+" with spaces first 129 | text = text.replace("+", " ") 130 | 131 | # Escape quotes by doubling them (AppleScript style) 132 | return text.replace('"', '""') 133 | 134 | def update_todo_direct(id: str, title: Optional[str] = None, notes: Optional[str] = None, 135 | when: Optional[str] = None, deadline: Optional[str] = None, 136 | tags: Optional[Union[List[str], str]] = None, add_tags: Optional[Union[List[str], str]] = None, 137 | checklist_items: Optional[List[str]] = None, completed: Optional[bool] = None, 138 | canceled: Optional[bool] = None) -> bool: 139 | """Update a todo directly using AppleScript. 140 | 141 | This bypasses URL schemes entirely to avoid authentication issues. 142 | 143 | Args: 144 | id: The ID of the todo to update 145 | title: New title for the todo 146 | notes: New notes for the todo 147 | when: New schedule for the todo (today, tomorrow, evening, anytime, someday, or YYYY-MM-DD) 148 | deadline: New deadline for the todo (YYYY-MM-DD) 149 | tags: New tags for the todo (replaces existing tags) 150 | add_tags: Tags to add to the todo (preserves existing tags) 151 | checklist_items: Checklist items to set for the todo (replaces existing items) 152 | completed: Mark as completed 153 | canceled: Mark as canceled 154 | 155 | Returns: 156 | True if successful, False otherwise 157 | """ 158 | import re 159 | 160 | # Build the AppleScript command to find and update the todo 161 | script_parts = ['tell application "Things3"'] 162 | script_parts.append('try') 163 | script_parts.append(f' set theTodo to to do id "{id}"') 164 | 165 | # Update properties one at a time 166 | if title: 167 | script_parts.append(f' set name of theTodo to "{escape_applescript_string(title)}"') 168 | 169 | if notes: 170 | script_parts.append(f' set notes of theTodo to "{escape_applescript_string(notes)}"') 171 | 172 | # Handle date-related properties 173 | if when: 174 | # Check if when is a date in YYYY-MM-DD format 175 | is_date_format = re.match(r'^\d{4}-\d{2}-\d{2}$', when) 176 | 177 | # Simple mapping of common 'when' values to AppleScript commands 178 | if when == 'today': 179 | script_parts.append(' move theTodo to list "Today"') 180 | elif when == 'tomorrow': 181 | script_parts.append(' set activation date of theTodo to ((current date) + (1 * days))') 182 | script_parts.append(' move theTodo to list "Upcoming"') 183 | elif when == 'evening': 184 | script_parts.append(' move theTodo to list "Evening"') 185 | elif when == 'anytime': 186 | script_parts.append(' move theTodo to list "Anytime"') 187 | elif when == 'someday': 188 | script_parts.append(' move theTodo to list "Someday"') 189 | elif is_date_format: 190 | # Handle YYYY-MM-DD format dates 191 | year, month, day = when.split('-') 192 | script_parts.append(f''' 193 | -- Set activation date with direct date string 194 | set dateString to "{when}" 195 | set newDate to date dateString 196 | set activation date of theTodo to newDate 197 | -- Move to the Upcoming list 198 | move theTodo to list "Upcoming" 199 | ''') 200 | else: 201 | # For other formats, just log a warning and don't try to set it 202 | logger.warning(f"Schedule format '{when}' not directly supported in this simplified version") 203 | 204 | if deadline: 205 | # Check if deadline is in YYYY-MM-DD format 206 | if re.match(r'^\d{4}-\d{2}-\d{2}$', deadline): 207 | year, month, day = deadline.split('-') 208 | script_parts.append(f''' 209 | -- Set deadline with direct date string 210 | set deadlineString to "{deadline}" 211 | set deadlineDate to date deadlineString 212 | set deadline of theTodo to deadlineDate 213 | ''') 214 | else: 215 | logger.warning(f"Invalid deadline format: {deadline}. Expected YYYY-MM-DD") 216 | 217 | # Handle tags (clearing and adding new ones) 218 | if tags is not None: 219 | # Convert string tags to list if needed 220 | if isinstance(tags, str): 221 | tags = [tags] 222 | 223 | if tags: 224 | # Clear existing tags first 225 | script_parts.append(' -- Clear existing tags') 226 | script_parts.append(' set tag_names of theTodo to {}') 227 | 228 | # Simplified tag handling 229 | import json 230 | tags_json = json.dumps(tags) 231 | script_parts.append(f''' 232 | -- Set tags using a list 233 | set tagNameList to {tags_json} 234 | -- Clear existing tags 235 | set oldTags to tags of theTodo 236 | repeat with t from (count of oldTags) to 1 by -1 237 | delete item t of oldTags 238 | end repeat 239 | -- Add new tags 240 | repeat with t from 1 to (count of tagNameList) 241 | set tagText to item t of tagNameList 242 | tell theTodo 243 | set newTag to make new tag 244 | set name of newTag to tagText 245 | end tell 246 | end repeat 247 | ''') 248 | else: 249 | # Clear all tags if empty list provided 250 | script_parts.append(' -- Clear all tags') 251 | script_parts.append(' set tag_names of theTodo to {}') 252 | 253 | # Handle adding tags without replacing existing ones 254 | if add_tags is not None: 255 | # Convert string to list if needed 256 | if isinstance(add_tags, str): 257 | add_tags = [add_tags] 258 | 259 | for tag in add_tags: 260 | tag_name = escape_applescript_string(tag) 261 | script_parts.append(f''' 262 | -- Add tag {tag_name} if it doesn't exist 263 | set tagFound to false 264 | repeat with t in tags of theTodo 265 | if name of t is "{tag_name}" then 266 | set tagFound to true 267 | exit repeat 268 | end if 269 | end repeat 270 | if not tagFound then 271 | tell theTodo to make new tag with properties {{name:"{tag_name}"}} 272 | end if 273 | ''') 274 | 275 | # Handle checklist items - simplified approach 276 | if checklist_items is not None: 277 | # Convert string to list if needed 278 | if isinstance(checklist_items, str): 279 | checklist_items = checklist_items.split('\n') 280 | 281 | if checklist_items: 282 | # For simplicity, we'll use JSON to pass checklist items 283 | import json 284 | items_json = json.dumps([item for item in checklist_items]) 285 | script_parts.append(f''' 286 | -- Clear and set checklist items 287 | set oldItems to check list items of theTodo 288 | repeat with i from (count of oldItems) to 1 by -1 289 | delete item i of oldItems 290 | end repeat 291 | 292 | set itemList to {items_json} 293 | repeat with i from 1 to (count of itemList) 294 | set itemText to item i of itemList 295 | tell theTodo 296 | set newItem to make new check list item 297 | set name of newItem to itemText 298 | end tell 299 | end repeat 300 | ''') 301 | 302 | # Handle completion status - use completion date approach 303 | if completed is not None: 304 | if completed: 305 | script_parts.append(' set status of theTodo to completed') 306 | else: 307 | script_parts.append(' set status of theTodo to open') 308 | 309 | # Handle canceled status 310 | if canceled is not None: 311 | if canceled: 312 | script_parts.append(' set status of theTodo to canceled') 313 | else: 314 | script_parts.append(' set status of theTodo to open') 315 | 316 | # Return true on success 317 | script_parts.append(' return true') 318 | script_parts.append('on error errMsg') 319 | script_parts.append(' log "Error updating todo: " & errMsg') 320 | script_parts.append(' return false') 321 | script_parts.append('end try') 322 | script_parts.append('end tell') 323 | 324 | # Execute the script 325 | script = '\n'.join(script_parts) 326 | logger.info(f"Executing AppleScript for update_todo_direct: \n{script}") 327 | 328 | result = run_applescript(script) 329 | 330 | if result == "true": 331 | logger.info(f"Successfully updated todo with ID: {id}") 332 | return True 333 | else: 334 | logger.error(f"AppleScript update_todo_direct failed: {result}") 335 | return False -------------------------------------------------------------------------------- /src/things_mcp/cache.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | Caching module for Things MCP server. 4 | Provides intelligent caching for frequently accessed data to improve performance. 5 | """ 6 | import time 7 | import json 8 | import hashlib 9 | import logging 10 | from typing import Any, Dict, Optional, Callable, Tuple 11 | from functools import wraps 12 | from threading import Lock 13 | 14 | logger = logging.getLogger(__name__) 15 | 16 | class ThingsCache: 17 | """ 18 | Thread-safe cache for Things data with TTL support. 19 | """ 20 | 21 | def __init__(self, default_ttl: int = 300): # 5 minutes default 22 | """ 23 | Initialize the cache. 24 | 25 | Args: 26 | default_ttl: Default time-to-live for cache entries in seconds 27 | """ 28 | self.cache: Dict[str, Tuple[Any, float]] = {} 29 | self.default_ttl = default_ttl 30 | self.lock = Lock() 31 | self.hit_count = 0 32 | self.miss_count = 0 33 | 34 | def _make_key(self, operation: str, **kwargs) -> str: 35 | """Generate a cache key from operation and parameters.""" 36 | # Sort kwargs to ensure consistent keys 37 | sorted_params = json.dumps(kwargs, sort_keys=True) 38 | key_string = f"{operation}:{sorted_params}" 39 | # Use hash for shorter keys 40 | return hashlib.md5(key_string.encode()).hexdigest() 41 | 42 | def get(self, operation: str, **kwargs) -> Optional[Any]: 43 | """ 44 | Get a value from cache if it exists and hasn't expired. 45 | 46 | Args: 47 | operation: The operation name 48 | **kwargs: Parameters for the operation 49 | 50 | Returns: 51 | Cached value if available and valid, None otherwise 52 | """ 53 | key = self._make_key(operation, **kwargs) 54 | 55 | with self.lock: 56 | if key in self.cache: 57 | value, expiry_time = self.cache[key] 58 | if time.time() < expiry_time: 59 | self.hit_count += 1 60 | logger.debug(f"Cache hit for {operation}: {key}") 61 | return value 62 | else: 63 | # Expired, remove it 64 | del self.cache[key] 65 | logger.debug(f"Cache expired for {operation}: {key}") 66 | 67 | self.miss_count += 1 68 | return None 69 | 70 | def set(self, operation: str, value: Any, ttl: Optional[int] = None, **kwargs) -> None: 71 | """ 72 | Set a value in the cache. 73 | 74 | Args: 75 | operation: The operation name 76 | value: The value to cache 77 | ttl: Time-to-live in seconds (uses default if not specified) 78 | **kwargs: Parameters for the operation 79 | """ 80 | key = self._make_key(operation, **kwargs) 81 | ttl = ttl if ttl is not None else self.default_ttl 82 | expiry_time = time.time() + ttl 83 | 84 | with self.lock: 85 | self.cache[key] = (value, expiry_time) 86 | logger.debug(f"Cache set for {operation}: {key}, TTL: {ttl}s") 87 | 88 | def invalidate(self, operation: Optional[str] = None, **kwargs) -> None: 89 | """ 90 | Invalidate cache entries. 91 | 92 | Args: 93 | operation: If specified, only invalidate entries for this operation 94 | **kwargs: If specified with operation, invalidate specific entry 95 | """ 96 | with self.lock: 97 | if operation and kwargs: 98 | # Invalidate specific entry 99 | key = self._make_key(operation, **kwargs) 100 | if key in self.cache: 101 | del self.cache[key] 102 | logger.debug(f"Invalidated specific cache entry: {key}") 103 | elif operation: 104 | # Invalidate all entries for an operation 105 | keys_to_remove = [k for k in self.cache.keys() 106 | if k.startswith(hashlib.md5(f"{operation}:".encode()).hexdigest()[:8])] 107 | for key in keys_to_remove: 108 | del self.cache[key] 109 | logger.debug(f"Invalidated {len(keys_to_remove)} cache entries for operation: {operation}") 110 | else: 111 | # Clear entire cache 112 | self.cache.clear() 113 | logger.info("Cleared entire cache") 114 | 115 | def cleanup_expired(self) -> int: 116 | """Remove expired entries from cache. Returns number of entries removed.""" 117 | current_time = time.time() 118 | removed_count = 0 119 | 120 | with self.lock: 121 | keys_to_remove = [] 122 | for key, (_, expiry_time) in self.cache.items(): 123 | if current_time >= expiry_time: 124 | keys_to_remove.append(key) 125 | 126 | for key in keys_to_remove: 127 | del self.cache[key] 128 | removed_count += 1 129 | 130 | if removed_count > 0: 131 | logger.debug(f"Cleaned up {removed_count} expired cache entries") 132 | 133 | return removed_count 134 | 135 | def get_stats(self) -> Dict[str, Any]: 136 | """Get cache statistics.""" 137 | with self.lock: 138 | total_requests = self.hit_count + self.miss_count 139 | hit_rate = (self.hit_count / total_requests * 100) if total_requests > 0 else 0 140 | 141 | return { 142 | "entries": len(self.cache), 143 | "hits": self.hit_count, 144 | "misses": self.miss_count, 145 | "hit_rate": f"{hit_rate:.1f}%", 146 | "total_requests": total_requests 147 | } 148 | 149 | # Global cache instance 150 | _cache = ThingsCache() 151 | 152 | def cached(ttl: Optional[int] = None, invalidate_on: Optional[list] = None): 153 | """ 154 | Decorator for caching function results. 155 | 156 | Args: 157 | ttl: Time-to-live for cached results in seconds 158 | invalidate_on: List of operation names that should invalidate this cache 159 | 160 | Example: 161 | @cached(ttl=60) 162 | def get_projects(): 163 | return things.projects() 164 | """ 165 | def decorator(func: Callable) -> Callable: 166 | @wraps(func) 167 | def wrapper(*args, **kwargs): 168 | # Create operation name from function 169 | operation = func.__name__ 170 | 171 | # Check cache first 172 | cached_value = _cache.get(operation, **kwargs) 173 | if cached_value is not None: 174 | return cached_value 175 | 176 | # Call the actual function 177 | result = func(*args, **kwargs) 178 | 179 | # Cache the result 180 | _cache.set(operation, result, ttl=ttl, **kwargs) 181 | 182 | return result 183 | 184 | # Store invalidation info 185 | if invalidate_on: 186 | wrapper._invalidate_on = invalidate_on 187 | 188 | return wrapper 189 | return decorator 190 | 191 | def invalidate_caches_for(operations: list) -> None: 192 | """ 193 | Invalidate caches for specific operations. 194 | 195 | This is useful when data is modified and related caches need to be cleared. 196 | 197 | Args: 198 | operations: List of operation names to invalidate 199 | """ 200 | for operation in operations: 201 | _cache.invalidate(operation) 202 | 203 | def get_cache_stats() -> Dict[str, Any]: 204 | """Get cache statistics.""" 205 | return _cache.get_stats() 206 | 207 | def clear_cache() -> None: 208 | """Clear all cached data.""" 209 | _cache.invalidate() 210 | 211 | # TTL configurations for different types of data 212 | CACHE_TTL = { 213 | "inbox": 30, # 30 seconds - changes frequently 214 | "today": 30, # 30 seconds - changes frequently 215 | "upcoming": 60, # 1 minute 216 | "anytime": 300, # 5 minutes 217 | "someday": 300, # 5 minutes 218 | "projects": 300, # 5 minutes 219 | "areas": 600, # 10 minutes - rarely changes 220 | "tags": 600, # 10 minutes - rarely changes 221 | "logbook": 300, # 5 minutes 222 | "trash": 300, # 5 minutes 223 | } 224 | 225 | # Auto-cleanup task 226 | def start_cache_cleanup_task(interval: int = 300): 227 | """Start a background task to clean up expired cache entries.""" 228 | import threading 229 | 230 | def cleanup_task(): 231 | while True: 232 | time.sleep(interval) 233 | _cache.cleanup_expired() 234 | 235 | thread = threading.Thread(target=cleanup_task, daemon=True) 236 | thread.start() 237 | logger.info(f"Started cache cleanup task with {interval}s interval") 238 | 239 | # Start cleanup task when module is imported 240 | start_cache_cleanup_task() -------------------------------------------------------------------------------- /src/things_mcp/config.py: -------------------------------------------------------------------------------- 1 | """ 2 | Configuration module for Things MCP. 3 | Stores settings and user-specific configuration values. 4 | """ 5 | import os 6 | import json 7 | import logging 8 | from pathlib import Path 9 | 10 | logger = logging.getLogger(__name__) 11 | 12 | # Default configuration values 13 | DEFAULT_CONFIG = { 14 | "things_auth_token": "", # Empty by default, must be set by user 15 | "retry_attempts": 3, 16 | "retry_delay": 1.0 17 | } 18 | 19 | # Path to the configuration file 20 | CONFIG_DIR = Path.home() / ".things-mcp" 21 | CONFIG_FILE = CONFIG_DIR / "config.json" 22 | 23 | # Global configuration dictionary 24 | _config = None 25 | 26 | def load_config(): 27 | """Load configuration from file, create with defaults if it doesn't exist.""" 28 | global _config 29 | 30 | # Create config directory if it doesn't exist 31 | if not CONFIG_DIR.exists(): 32 | try: 33 | CONFIG_DIR.mkdir(parents=True) 34 | logger.info(f"Created configuration directory: {CONFIG_DIR}") 35 | except Exception as e: 36 | logger.error(f"Failed to create config directory: {e}") 37 | return DEFAULT_CONFIG 38 | 39 | # Check if config file exists 40 | if not CONFIG_FILE.exists(): 41 | # Create default config file 42 | try: 43 | with open(CONFIG_FILE, 'w') as f: 44 | json.dump(DEFAULT_CONFIG, f, indent=2) 45 | logger.info(f"Created default configuration file: {CONFIG_FILE}") 46 | _config = DEFAULT_CONFIG.copy() 47 | except Exception as e: 48 | logger.error(f"Failed to create config file: {e}") 49 | _config = DEFAULT_CONFIG.copy() 50 | else: 51 | # Load existing config 52 | try: 53 | with open(CONFIG_FILE, 'r') as f: 54 | loaded_config = json.load(f) 55 | 56 | # Ensure all required keys are present 57 | _config = DEFAULT_CONFIG.copy() 58 | _config.update(loaded_config) 59 | 60 | logger.info(f"Loaded configuration from: {CONFIG_FILE}") 61 | except Exception as e: 62 | logger.error(f"Failed to load config file: {e}") 63 | _config = DEFAULT_CONFIG.copy() 64 | 65 | return _config 66 | 67 | def save_config(): 68 | """Save current configuration to file.""" 69 | if _config is None: 70 | logger.error("Cannot save config: No configuration loaded") 71 | return False 72 | 73 | try: 74 | with open(CONFIG_FILE, 'w') as f: 75 | json.dump(_config, f, indent=2) 76 | logger.info(f"Saved configuration to: {CONFIG_FILE}") 77 | return True 78 | except Exception as e: 79 | logger.error(f"Failed to save config: {e}") 80 | return False 81 | 82 | def get_config(): 83 | """Get the current configuration, loading it if necessary.""" 84 | global _config 85 | if _config is None: 86 | _config = load_config() 87 | return _config 88 | 89 | def set_config_value(key, value): 90 | """Set a configuration value and save to file.""" 91 | config = get_config() 92 | config[key] = value 93 | return save_config() 94 | 95 | def get_config_value(key, default=None): 96 | """Get a configuration value, return default if not found.""" 97 | config = get_config() 98 | return config.get(key, default) 99 | 100 | def get_things_auth_token(): 101 | """Get the Things authentication token. 102 | 103 | First checks environment variable THINGS_AUTH_TOKEN, 104 | then falls back to config file. 105 | """ 106 | # Check environment variable first 107 | token = os.environ.get('THINGS_AUTH_TOKEN') 108 | if token: 109 | logger.debug("Using Things auth token from environment variable") 110 | return token 111 | 112 | # Fall back to config file 113 | return get_config_value("things_auth_token", "") 114 | 115 | def set_things_auth_token(token): 116 | """Set the Things authentication token.""" 117 | return set_config_value("things_auth_token", token) 118 | 119 | # Initialize configuration on module import 120 | load_config() 121 | -------------------------------------------------------------------------------- /src/things_mcp/fast_server.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | Things MCP Server implementation using the FastMCP pattern. 4 | This provides a more modern and maintainable approach to the Things integration. 5 | """ 6 | import logging 7 | import asyncio 8 | import traceback 9 | from typing import Dict, Any, Optional, List, Union 10 | import things 11 | 12 | from mcp.server.fastmcp import FastMCP 13 | import mcp.types as types 14 | 15 | # Import supporting modules 16 | from .formatters import format_todo, format_project, format_area, format_tag 17 | from .utils import app_state, circuit_breaker, dead_letter_queue, rate_limiter 18 | from .url_scheme import ( 19 | add_todo, add_project, update_todo, update_project, show, 20 | search, launch_things, execute_url 21 | ) 22 | 23 | # Import and configure enhanced logging 24 | from .logging_config import setup_logging, get_logger, log_operation_start, log_operation_end 25 | # Import caching 26 | from .cache import cached, invalidate_caches_for, get_cache_stats, CACHE_TTL 27 | 28 | # Configure enhanced logging 29 | setup_logging(console_level="INFO", file_level="DEBUG", structured_logs=True) 30 | logger = get_logger(__name__) 31 | 32 | # Create the FastMCP server 33 | mcp = FastMCP( 34 | "Things", 35 | description="Interact with the Things task management app", 36 | version="0.1.1" 37 | ) 38 | 39 | # LIST VIEWS 40 | 41 | @mcp.tool(name="get-inbox") 42 | def get_inbox() -> str: 43 | """Get todos from Inbox""" 44 | import time 45 | start_time = time.time() 46 | log_operation_start("get-inbox") 47 | 48 | try: 49 | todos = things.inbox() 50 | 51 | if not todos: 52 | log_operation_end("get-inbox", True, time.time() - start_time, count=0) 53 | return "No items found in Inbox" 54 | 55 | formatted_todos = [format_todo(todo) for todo in todos] 56 | log_operation_end("get-inbox", True, time.time() - start_time, count=len(todos)) 57 | return "\n\n---\n\n".join(formatted_todos) 58 | except Exception as e: 59 | log_operation_end("get-inbox", False, time.time() - start_time, error=str(e)) 60 | raise 61 | 62 | @mcp.tool(name="get-today") 63 | @cached(ttl=CACHE_TTL.get("today", 30)) 64 | def get_today() -> str: 65 | """Get todos due today""" 66 | import time 67 | start_time = time.time() 68 | log_operation_start("get-today") 69 | 70 | try: 71 | todos = things.today() 72 | 73 | if not todos: 74 | log_operation_end("get-today", True, time.time() - start_time, count=0) 75 | return "No items due today" 76 | 77 | formatted_todos = [format_todo(todo) for todo in todos] 78 | log_operation_end("get-today", True, time.time() - start_time, count=len(todos)) 79 | return "\n\n---\n\n".join(formatted_todos) 80 | except Exception as e: 81 | log_operation_end("get-today", False, time.time() - start_time, error=str(e)) 82 | raise 83 | 84 | @mcp.tool(name="get-upcoming") 85 | def get_upcoming() -> str: 86 | """Get upcoming todos""" 87 | todos = things.upcoming() 88 | 89 | if not todos: 90 | return "No upcoming items" 91 | 92 | formatted_todos = [format_todo(todo) for todo in todos] 93 | return "\n\n---\n\n".join(formatted_todos) 94 | 95 | @mcp.tool(name="get-anytime") 96 | def get_anytime() -> str: 97 | """Get todos from Anytime list""" 98 | todos = things.anytime() 99 | 100 | if not todos: 101 | return "No items in Anytime list" 102 | 103 | formatted_todos = [format_todo(todo) for todo in todos] 104 | return "\n\n---\n\n".join(formatted_todos) 105 | 106 | @mcp.tool(name="get-someday") 107 | def get_someday() -> str: 108 | """Get todos from Someday list""" 109 | todos = things.someday() 110 | 111 | if not todos: 112 | return "No items in Someday list" 113 | 114 | formatted_todos = [format_todo(todo) for todo in todos] 115 | return "\n\n---\n\n".join(formatted_todos) 116 | 117 | @mcp.tool(name="get-logbook") 118 | def get_logbook(period: str = "7d", limit: int = 50) -> str: 119 | """ 120 | Get completed todos from Logbook, defaults to last 7 days 121 | 122 | Args: 123 | period: Time period to look back (e.g., '3d', '1w', '2m', '1y'). Defaults to '7d' 124 | limit: Maximum number of entries to return. Defaults to 50 125 | """ 126 | todos = things.last(period, status='completed') 127 | 128 | if not todos: 129 | return "No completed items found" 130 | 131 | if todos and len(todos) > limit: 132 | todos = todos[:limit] 133 | 134 | formatted_todos = [format_todo(todo) for todo in todos] 135 | return "\n\n---\n\n".join(formatted_todos) 136 | 137 | @mcp.tool(name="get-trash") 138 | def get_trash() -> str: 139 | """Get trashed todos""" 140 | todos = things.trash() 141 | 142 | if not todos: 143 | return "No items in trash" 144 | 145 | formatted_todos = [format_todo(todo) for todo in todos] 146 | return "\n\n---\n\n".join(formatted_todos) 147 | 148 | # BASIC TODO OPERATIONS 149 | 150 | @mcp.tool(name="get-todos") 151 | def get_todos(project_uuid: Optional[str] = None, include_items: bool = True) -> str: 152 | """ 153 | Get todos from Things, optionally filtered by project 154 | 155 | Args: 156 | project_uuid: Optional UUID of a specific project to get todos from 157 | include_items: Include checklist items 158 | """ 159 | if project_uuid: 160 | project = things.get(project_uuid) 161 | if not project or project.get('type') != 'project': 162 | return f"Error: Invalid project UUID '{project_uuid}'" 163 | 164 | todos = things.todos(project=project_uuid, start=None) 165 | 166 | if not todos: 167 | return "No todos found" 168 | 169 | formatted_todos = [format_todo(todo) for todo in todos] 170 | return "\n\n---\n\n".join(formatted_todos) 171 | 172 | @mcp.tool(name="get-projects") 173 | def get_projects(include_items: bool = False) -> str: 174 | """ 175 | Get all projects from Things 176 | 177 | Args: 178 | include_items: Include tasks within projects 179 | """ 180 | projects = things.projects() 181 | 182 | if not projects: 183 | return "No projects found" 184 | 185 | formatted_projects = [format_project(project, include_items) for project in projects] 186 | return "\n\n---\n\n".join(formatted_projects) 187 | 188 | @mcp.tool(name="get-areas") 189 | def get_areas(include_items: bool = False) -> str: 190 | """ 191 | Get all areas from Things 192 | 193 | Args: 194 | include_items: Include projects and tasks within areas 195 | """ 196 | areas = things.areas() 197 | 198 | if not areas: 199 | return "No areas found" 200 | 201 | formatted_areas = [format_area(area, include_items) for area in areas] 202 | return "\n\n---\n\n".join(formatted_areas) 203 | 204 | # TAG OPERATIONS 205 | 206 | @mcp.tool(name="get-tags") 207 | def get_tags(include_items: bool = False) -> str: 208 | """ 209 | Get all tags 210 | 211 | Args: 212 | include_items: Include items tagged with each tag 213 | """ 214 | tags = things.tags() 215 | 216 | if not tags: 217 | return "No tags found" 218 | 219 | formatted_tags = [format_tag(tag, include_items) for tag in tags] 220 | return "\n\n---\n\n".join(formatted_tags) 221 | 222 | @mcp.tool(name="get-tagged-items") 223 | def get_tagged_items(tag: str) -> str: 224 | """ 225 | Get items with a specific tag 226 | 227 | Args: 228 | tag: Tag title to filter by 229 | """ 230 | todos = things.todos(tag=tag) 231 | 232 | if not todos: 233 | return f"No items found with tag '{tag}'" 234 | 235 | formatted_todos = [format_todo(todo) for todo in todos] 236 | return "\n\n---\n\n".join(formatted_todos) 237 | 238 | # SEARCH OPERATIONS 239 | 240 | @mcp.tool(name="search-todos") 241 | def search_todos(query: str) -> str: 242 | """ 243 | Search todos by title or notes 244 | 245 | Args: 246 | query: Search term to look for in todo titles and notes 247 | """ 248 | todos = things.search(query) 249 | 250 | if not todos: 251 | return f"No todos found matching '{query}'" 252 | 253 | formatted_todos = [format_todo(todo) for todo in todos] 254 | return "\n\n---\n\n".join(formatted_todos) 255 | 256 | @mcp.tool(name="search-advanced") 257 | def search_advanced( 258 | status: Optional[str] = None, 259 | start_date: Optional[str] = None, 260 | deadline: Optional[str] = None, 261 | tag: Optional[str] = None, 262 | area: Optional[str] = None, 263 | type: Optional[str] = None 264 | ) -> str: 265 | """ 266 | Advanced todo search with multiple filters 267 | 268 | Args: 269 | status: Filter by todo status (incomplete/completed/canceled) 270 | start_date: Filter by start date (YYYY-MM-DD) 271 | deadline: Filter by deadline (YYYY-MM-DD) 272 | tag: Filter by tag 273 | area: Filter by area UUID 274 | type: Filter by item type (to-do/project/heading) 275 | """ 276 | # Build filter parameters 277 | kwargs = {} 278 | 279 | # Add filters that are provided 280 | if status: 281 | kwargs['status'] = status 282 | if deadline: 283 | kwargs['deadline'] = deadline 284 | if start_date: 285 | kwargs['start'] = start_date 286 | if tag: 287 | kwargs['tag'] = tag 288 | if area: 289 | kwargs['area'] = area 290 | if type: 291 | kwargs['type'] = type 292 | 293 | # Execute search with applicable filters 294 | try: 295 | todos = things.todos(**kwargs) 296 | 297 | if not todos: 298 | return "No items found matching your search criteria" 299 | 300 | formatted_todos = [format_todo(todo) for todo in todos] 301 | return "\n\n---\n\n".join(formatted_todos) 302 | except Exception as e: 303 | return f"Error in advanced search: {str(e)}" 304 | 305 | # MODIFICATION OPERATIONS 306 | 307 | @mcp.tool(name="add-todo") 308 | def add_task( 309 | title: str, 310 | notes: Optional[str] = None, 311 | when: Optional[str] = None, 312 | deadline: Optional[str] = None, 313 | tags: Optional[List[str]] = None, 314 | checklist_items: Optional[List[str]] = None, 315 | list_id: Optional[str] = None, 316 | list_title: Optional[str] = None, 317 | heading: Optional[str] = None 318 | ) -> str: 319 | """ 320 | Create a new todo in Things 321 | 322 | Args: 323 | title: Title of the todo 324 | notes: Notes for the todo 325 | when: When to schedule the todo (today, tomorrow, evening, anytime, someday, or YYYY-MM-DD) 326 | deadline: Deadline for the todo (YYYY-MM-DD) 327 | tags: Tags to apply to the todo 328 | checklist_items: Checklist items to add 329 | list_id: ID of project/area to add to 330 | list_title: Title of project/area to add to 331 | heading: Heading to add under 332 | """ 333 | try: 334 | # Ensure Things app is running 335 | if not app_state.update_app_state(): 336 | if not launch_things(): 337 | return "Error: Unable to launch Things app" 338 | 339 | # Execute the add_todo URL command 340 | result = add_todo( 341 | title=title, 342 | notes=notes, 343 | when=when, 344 | deadline=deadline, 345 | tags=tags, 346 | checklist_items=checklist_items, 347 | list_id=list_id, 348 | list_title=list_title, 349 | heading=heading 350 | ) 351 | 352 | if not result: 353 | return "Error: Failed to create todo" 354 | 355 | # Invalidate relevant caches after creating a todo 356 | invalidate_caches_for(["get-inbox", "get-today", "get-upcoming", "get-todos"]) 357 | 358 | return f"Successfully created todo: {title}" 359 | except Exception as e: 360 | logger.error(f"Error creating todo: {str(e)}") 361 | return f"Error creating todo: {str(e)}" 362 | 363 | @mcp.tool(name="add-project") 364 | def add_new_project( 365 | title: str, 366 | notes: Optional[str] = None, 367 | when: Optional[str] = None, 368 | deadline: Optional[str] = None, 369 | tags: Optional[List[str]] = None, 370 | area_id: Optional[str] = None, 371 | area_title: Optional[str] = None, 372 | todos: Optional[List[str]] = None 373 | ) -> str: 374 | """ 375 | Create a new project in Things 376 | 377 | Args: 378 | title: Title of the project 379 | notes: Notes for the project 380 | when: When to schedule the project 381 | deadline: Deadline for the project 382 | tags: Tags to apply to the project 383 | area_id: ID of area to add to 384 | area_title: Title of area to add to 385 | todos: Initial todos to create in the project 386 | """ 387 | try: 388 | # Ensure Things app is running 389 | if not app_state.update_app_state(): 390 | if not launch_things(): 391 | return "Error: Unable to launch Things app" 392 | 393 | # Execute the add_project URL command 394 | result = add_project( 395 | title=title, 396 | notes=notes, 397 | when=when, 398 | deadline=deadline, 399 | tags=tags, 400 | area_id=area_id, 401 | area_title=area_title, 402 | todos=todos 403 | ) 404 | 405 | if not result: 406 | return "Error: Failed to create project" 407 | 408 | return f"Successfully created project: {title}" 409 | except Exception as e: 410 | logger.error(f"Error creating project: {str(e)}") 411 | return f"Error creating project: {str(e)}" 412 | 413 | @mcp.tool(name="update-todo") 414 | def update_task( 415 | id: str, 416 | title: Optional[str] = None, 417 | notes: Optional[str] = None, 418 | when: Optional[str] = None, 419 | deadline: Optional[str] = None, 420 | tags: Optional[List[str]] = None, 421 | completed: Optional[bool] = None, 422 | canceled: Optional[bool] = None 423 | ) -> str: 424 | """ 425 | Update an existing todo in Things 426 | 427 | Args: 428 | id: ID of the todo to update 429 | title: New title 430 | notes: New notes 431 | when: New schedule 432 | deadline: New deadline 433 | tags: New tags 434 | completed: Mark as completed 435 | canceled: Mark as canceled 436 | """ 437 | try: 438 | # Ensure Things app is running 439 | if not app_state.update_app_state(): 440 | if not launch_things(): 441 | return "Error: Unable to launch Things app" 442 | 443 | # Execute the update_todo URL command 444 | result = update_todo( 445 | id=id, 446 | title=title, 447 | notes=notes, 448 | when=when, 449 | deadline=deadline, 450 | tags=tags, 451 | completed=completed, 452 | canceled=canceled 453 | ) 454 | 455 | if not result: 456 | return "Error: Failed to update todo" 457 | 458 | return f"Successfully updated todo with ID: {id}" 459 | except Exception as e: 460 | logger.error(f"Error updating todo: {str(e)}") 461 | return f"Error updating todo: {str(e)}" 462 | 463 | @mcp.tool(name="update-project") 464 | def update_existing_project( 465 | id: str, 466 | title: Optional[str] = None, 467 | notes: Optional[str] = None, 468 | when: Optional[str] = None, 469 | deadline: Optional[str] = None, 470 | tags: Optional[List[str]] = None, 471 | completed: Optional[bool] = None, 472 | canceled: Optional[bool] = None 473 | ) -> str: 474 | """ 475 | Update an existing project in Things 476 | 477 | Args: 478 | id: ID of the project to update 479 | title: New title 480 | notes: New notes 481 | when: New schedule 482 | deadline: New deadline 483 | tags: New tags 484 | completed: Mark as completed 485 | canceled: Mark as canceled 486 | """ 487 | try: 488 | # Ensure Things app is running 489 | if not app_state.update_app_state(): 490 | if not launch_things(): 491 | return "Error: Unable to launch Things app" 492 | 493 | # Execute the update_project URL command 494 | result = update_project( 495 | id=id, 496 | title=title, 497 | notes=notes, 498 | when=when, 499 | deadline=deadline, 500 | tags=tags, 501 | completed=completed, 502 | canceled=canceled 503 | ) 504 | 505 | if not result: 506 | return "Error: Failed to update project" 507 | 508 | return f"Successfully updated project with ID: {id}" 509 | except Exception as e: 510 | logger.error(f"Error updating project: {str(e)}") 511 | return f"Error updating project: {str(e)}" 512 | 513 | @mcp.tool(name="show-item") 514 | def show_item( 515 | id: str, 516 | query: Optional[str] = None, 517 | filter_tags: Optional[List[str]] = None 518 | ) -> str: 519 | """ 520 | Show a specific item or list in Things 521 | 522 | Args: 523 | id: ID of item to show, or one of: inbox, today, upcoming, anytime, someday, logbook 524 | query: Optional query to filter by 525 | filter_tags: Optional tags to filter by 526 | """ 527 | try: 528 | # Ensure Things app is running 529 | if not app_state.update_app_state(): 530 | if not launch_things(): 531 | return "Error: Unable to launch Things app" 532 | 533 | # Execute the show URL command 534 | result = show( 535 | id=id, 536 | query=query, 537 | filter_tags=filter_tags 538 | ) 539 | 540 | if not result: 541 | return f"Error: Failed to show item/list '{id}'" 542 | 543 | return f"Successfully opened '{id}' in Things" 544 | except Exception as e: 545 | logger.error(f"Error showing item: {str(e)}") 546 | return f"Error showing item: {str(e)}" 547 | 548 | @mcp.tool(name="search-items") 549 | def search_all_items(query: str) -> str: 550 | """ 551 | Search for items in Things 552 | 553 | Args: 554 | query: Search query 555 | """ 556 | try: 557 | # Ensure Things app is running 558 | if not app_state.update_app_state(): 559 | if not launch_things(): 560 | return "Error: Unable to launch Things app" 561 | 562 | # Execute the search URL command 563 | result = search(query=query) 564 | 565 | if not result: 566 | return f"Error: Failed to search for '{query}'" 567 | 568 | return f"Successfully searched for '{query}' in Things" 569 | except Exception as e: 570 | logger.error(f"Error searching: {str(e)}") 571 | return f"Error searching: {str(e)}" 572 | 573 | @mcp.tool(name="get-recent") 574 | def get_recent(period: str) -> str: 575 | """ 576 | Get recently created items 577 | 578 | Args: 579 | period: Time period (e.g., '3d', '1w', '2m', '1y') 580 | """ 581 | try: 582 | # Check if period format is valid 583 | if not period or not any(period.endswith(unit) for unit in ['d', 'w', 'm', 'y']): 584 | return "Error: Period must be in format '3d', '1w', '2m', '1y'" 585 | 586 | # Get recent items 587 | items = things.last(period) 588 | 589 | if not items: 590 | return f"No items found in the last {period}" 591 | 592 | formatted_items = [] 593 | for item in items: 594 | if item.get('type') == 'to-do': 595 | formatted_items.append(format_todo(item)) 596 | elif item.get('type') == 'project': 597 | formatted_items.append(format_project(item, include_items=False)) 598 | 599 | return "\n\n---\n\n".join(formatted_items) 600 | except Exception as e: 601 | logger.error(f"Error getting recent items: {str(e)}") 602 | return f"Error getting recent items: {str(e)}" 603 | 604 | @mcp.tool(name="get-cache-stats") 605 | def get_cache_statistics() -> str: 606 | """Get cache performance statistics""" 607 | stats = get_cache_stats() 608 | 609 | return f"""Cache Statistics: 610 | - Total entries: {stats['entries']} 611 | - Cache hits: {stats['hits']} 612 | - Cache misses: {stats['misses']} 613 | - Hit rate: {stats['hit_rate']} 614 | - Total requests: {stats['total_requests']}""" 615 | 616 | # Main entry point 617 | def run_things_mcp_server(): 618 | """Run the Things MCP server""" 619 | # Check if Things app is available 620 | if not app_state.update_app_state(): 621 | logger.warning("Things app is not running at startup. MCP will attempt to launch it when needed.") 622 | try: 623 | # Try to launch Things 624 | if launch_things(): 625 | logger.info("Successfully launched Things app") 626 | else: 627 | logger.error("Unable to launch Things app. Some operations may fail.") 628 | except Exception as e: 629 | logger.error(f"Error launching Things app: {str(e)}") 630 | else: 631 | logger.info("Things app is running and ready for operations") 632 | 633 | # Run the MCP server 634 | mcp.run() 635 | 636 | if __name__ == "__main__": 637 | run_things_mcp_server() 638 | -------------------------------------------------------------------------------- /src/things_mcp/formatters.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import things 3 | 4 | logger = logging.getLogger(__name__) 5 | 6 | def format_todo(todo: dict) -> str: 7 | """Helper function to format a single todo into a readable string.""" 8 | logger.debug(f"Formatting todo: {todo}") 9 | todo_text = f"Title: {todo['title']}" 10 | 11 | # Add UUID for reference 12 | todo_text += f"\nUUID: {todo['uuid']}" 13 | 14 | # Add type 15 | todo_text += f"\nType: {todo['type']}" 16 | 17 | # Add status if present 18 | if todo.get('status'): 19 | todo_text += f"\nStatus: {todo['status']}" 20 | 21 | # Add start/list location 22 | if todo.get('start'): 23 | todo_text += f"\nList: {todo['start']}" 24 | 25 | # Add dates 26 | if todo.get('start_date'): 27 | todo_text += f"\nStart Date: {todo['start_date']}" 28 | if todo.get('deadline'): 29 | todo_text += f"\nDeadline: {todo['deadline']}" 30 | if todo.get('stop_date'): # Completion date 31 | todo_text += f"\nCompleted: {todo['stop_date']}" 32 | 33 | # Add notes if present 34 | if todo.get('notes'): 35 | todo_text += f"\nNotes: {todo['notes']}" 36 | 37 | # Add project info if present 38 | if todo.get('project'): 39 | try: 40 | project = things.get(todo['project']) 41 | if project: 42 | todo_text += f"\nProject: {project['title']}" 43 | except Exception: 44 | pass 45 | 46 | # Add area info if present 47 | if todo.get('area'): 48 | try: 49 | area = things.get(todo['area']) 50 | if area: 51 | todo_text += f"\nArea: {area['title']}" 52 | except Exception: 53 | pass 54 | 55 | # Add tags if present 56 | if todo.get('tags'): 57 | todo_text += f"\nTags: {', '.join(todo['tags'])}" 58 | 59 | # Add checklist if present and contains items 60 | if isinstance(todo.get('checklist'), list): 61 | todo_text += "\nChecklist:" 62 | for item in todo['checklist']: 63 | status = "✓" if item['status'] == 'completed' else "□" 64 | todo_text += f"\n {status} {item['title']}" 65 | 66 | return todo_text 67 | 68 | def format_project(project: dict, include_items: bool = False) -> str: 69 | """Helper function to format a single project.""" 70 | project_text = f"Title: {project['title']}\nUUID: {project['uuid']}" 71 | 72 | if project.get('area'): 73 | try: 74 | area = things.get(project['area']) 75 | if area: 76 | project_text += f"\nArea: {area['title']}" 77 | except Exception: 78 | pass 79 | 80 | if project.get('notes'): 81 | project_text += f"\nNotes: {project['notes']}" 82 | 83 | if include_items: 84 | todos = things.todos(project=project['uuid']) 85 | if todos: 86 | project_text += "\n\nTasks:" 87 | for todo in todos: 88 | project_text += f"\n- {todo['title']}" 89 | 90 | return project_text 91 | 92 | def format_area(area: dict, include_items: bool = False) -> str: 93 | """Helper function to format a single area.""" 94 | area_text = f"Title: {area['title']}\nUUID: {area['uuid']}" 95 | 96 | if area.get('notes'): 97 | area_text += f"\nNotes: {area['notes']}" 98 | 99 | if include_items: 100 | projects = things.projects(area=area['uuid']) 101 | if projects: 102 | area_text += "\n\nProjects:" 103 | for project in projects: 104 | area_text += f"\n- {project['title']}" 105 | 106 | todos = things.todos(area=area['uuid']) 107 | if todos: 108 | area_text += "\n\nTasks:" 109 | for todo in todos: 110 | area_text += f"\n- {todo['title']}" 111 | 112 | return area_text 113 | 114 | def format_tag(tag: dict, include_items: bool = False) -> str: 115 | """Helper function to format a single tag.""" 116 | tag_text = f"Title: {tag['title']}\nUUID: {tag['uuid']}" 117 | 118 | if tag.get('shortcut'): 119 | tag_text += f"\nShortcut: {tag['shortcut']}" 120 | 121 | if include_items: 122 | todos = things.todos(tag=tag['title']) 123 | if todos: 124 | tag_text += "\n\nTagged Items:" 125 | for todo in todos: 126 | tag_text += f"\n- {todo['title']}" 127 | 128 | return tag_text -------------------------------------------------------------------------------- /src/things_mcp/handlers.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from typing import List, Dict, Any, Optional, Callable 3 | import things 4 | import mcp.types as types 5 | import traceback 6 | import random 7 | from .formatters import format_todo, format_project, format_area, format_tag 8 | from . import url_scheme 9 | import time 10 | import subprocess 11 | from .applescript_bridge import run_applescript 12 | 13 | # Import reliability enhancements 14 | from .utils import app_state, circuit_breaker, dead_letter_queue, rate_limiter, validate_tool_registration 15 | 16 | logger = logging.getLogger(__name__) 17 | 18 | def retry_operation(func, max_retries=3, delay=1, operation_name=None, params=None): 19 | """Retry a function call with exponential backoff and jitter. 20 | 21 | Args: 22 | func: The function to call 23 | max_retries: Maximum number of retry attempts 24 | delay: Initial delay between retries in seconds 25 | operation_name: Name of the operation for logging and DLQ (optional) 26 | params: Parameters for the operation (optional) 27 | 28 | Returns: 29 | The result of the function call if successful, False if all retries fail 30 | """ 31 | # Check if Things app is available 32 | if not app_state.wait_for_app_availability(timeout=5): 33 | logger.error("Things app is not available for operation") 34 | return False 35 | 36 | # Check circuit breaker 37 | if not circuit_breaker.allow_operation(): 38 | logger.warning("Circuit breaker is open, blocking operation") 39 | return False 40 | 41 | last_exception = None 42 | for attempt in range(max_retries): 43 | try: 44 | result = func() 45 | if result: 46 | circuit_breaker.record_success() 47 | return result 48 | # If we got a result but it's falsey, record it as a failure 49 | circuit_breaker.record_failure() 50 | last_exception = Exception("Operation returned False") 51 | except Exception as e: 52 | last_exception = e 53 | circuit_breaker.record_failure() 54 | if attempt < max_retries - 1: 55 | # Add jitter to prevent thundering herd problem 56 | jitter = random.uniform(0.8, 1.2) 57 | wait_time = delay * (2 ** attempt) * jitter 58 | logger.warning(f"Attempt {attempt+1} failed. Retrying in {wait_time:.2f} seconds: {str(e)}") 59 | time.sleep(wait_time) 60 | else: 61 | logger.error(f"All {max_retries} attempts failed. Last error: {str(e)}") 62 | 63 | # If we have operation details, add to dead letter queue 64 | if operation_name and params and last_exception: 65 | dead_letter_queue.add_failed_operation( 66 | operation_name, 67 | params, 68 | str(last_exception), 69 | attempts=max_retries 70 | ) 71 | 72 | return False 73 | 74 | 75 | async def handle_tool_call( 76 | name: str, 77 | arguments: dict | None 78 | ) -> list[types.TextContent | types.ImageContent | types.EmbeddedResource]: 79 | """Handle tool execution requests. 80 | 81 | Attempts to execute the requested Things action with enhanced reliability. 82 | Uses circuit breaker, app state management, and retry logic for resilience. 83 | """ 84 | # Import url_scheme inside the function to avoid scope issues 85 | import url_scheme 86 | try: 87 | # List view handlers 88 | if name in ["get-inbox", "get-today", "get-upcoming", "get-anytime", 89 | "get-someday", "get-logbook", "get-trash"]: 90 | list_funcs = { 91 | "get-inbox": things.inbox, 92 | "get-today": things.today, 93 | "get-upcoming": things.upcoming, 94 | "get-anytime": things.anytime, 95 | "get-someday": things.someday, 96 | "get-trash": things.trash, 97 | } 98 | 99 | if name == "get-logbook": 100 | # Handle logbook with limits 101 | period = arguments.get("period", "7d") if arguments else "7d" 102 | limit = arguments.get("limit", 50) if arguments else 50 103 | todos = things.last(period, status='completed') 104 | if todos and len(todos) > limit: 105 | todos = todos[:limit] 106 | else: 107 | todos = list_funcs[name]() 108 | 109 | if not todos: 110 | return [types.TextContent(type="text", text="No items found")] 111 | 112 | formatted_todos = [format_todo(todo) for todo in todos] 113 | return [types.TextContent(type="text", text="\n\n---\n\n".join(formatted_todos))] 114 | 115 | # Basic todo operations 116 | elif name == "get-todos": 117 | project_uuid = arguments.get("project_uuid") if arguments else None 118 | include_items = arguments.get( 119 | "include_items", True) if arguments else True 120 | 121 | if project_uuid: 122 | project = things.get(project_uuid) 123 | if not project or project.get('type') != 'project': 124 | return [types.TextContent(type="text", 125 | text=f"Error: Invalid project UUID '{project_uuid}'")] 126 | 127 | todos = things.todos(project=project_uuid, start=None) 128 | if not todos: 129 | return [types.TextContent(type="text", text="No todos found")] 130 | 131 | formatted_todos = [format_todo(todo) for todo in todos] 132 | return [types.TextContent(type="text", text="\n\n---\n\n".join(formatted_todos))] 133 | 134 | # Project operations 135 | elif name == "get-projects": 136 | include_items = arguments.get( 137 | "include_items", False) if arguments else False 138 | projects = things.projects() 139 | 140 | if not projects: 141 | return [types.TextContent(type="text", text="No projects found")] 142 | 143 | formatted_projects = [format_project( 144 | project, include_items) for project in projects] 145 | return [types.TextContent(type="text", text="\n\n---\n\n".join(formatted_projects))] 146 | 147 | # Area operations 148 | elif name == "get-areas": 149 | include_items = arguments.get( 150 | "include_items", False) if arguments else False 151 | areas = things.areas() 152 | 153 | if not areas: 154 | return [types.TextContent(type="text", text="No areas found")] 155 | 156 | formatted_areas = [format_area( 157 | area, include_items) for area in areas] 158 | return [types.TextContent(type="text", text="\n\n---\n\n".join(formatted_areas))] 159 | 160 | # Tag operations 161 | elif name == "get-tags": 162 | include_items = arguments.get( 163 | "include_items", False) if arguments else False 164 | tags = things.tags() 165 | 166 | if not tags: 167 | return [types.TextContent(type="text", text="No tags found")] 168 | 169 | formatted_tags = [format_tag(tag, include_items) for tag in tags] 170 | return [types.TextContent(type="text", text="\n\n---\n\n".join(formatted_tags))] 171 | 172 | elif name == "get-tagged-items": 173 | if not arguments or "tag" not in arguments: 174 | raise ValueError("Missing tag parameter") 175 | 176 | tag = arguments["tag"] 177 | todos = things.todos(tag=tag) 178 | 179 | if not todos: 180 | return [types.TextContent(type="text", 181 | text=f"No items found with tag '{tag}'")] 182 | 183 | formatted_todos = [format_todo(todo) for todo in todos] 184 | return [types.TextContent(type="text", text="\n\n---\n\n".join(formatted_todos))] 185 | 186 | # Search operations 187 | elif name == "search-todos": 188 | if not arguments or "query" not in arguments: 189 | raise ValueError("Missing query parameter") 190 | 191 | query = arguments["query"] 192 | todos = things.search(query) 193 | 194 | if not todos: 195 | return [types.TextContent(type="text", 196 | text=f"No todos found matching '{query}'")] 197 | 198 | formatted_todos = [format_todo(todo) for todo in todos] 199 | return [types.TextContent(type="text", text="\n\n---\n\n".join(formatted_todos))] 200 | 201 | elif name == "search-advanced": 202 | if not arguments: 203 | raise ValueError("Missing search parameters") 204 | 205 | # Convert the arguments to things.todos() parameters 206 | search_params = {} 207 | 208 | # Handle status 209 | if "status" in arguments: 210 | search_params["status"] = arguments["status"] 211 | 212 | # Handle dates 213 | if "start_date" in arguments: 214 | search_params["start_date"] = arguments["start_date"] 215 | if "deadline" in arguments: 216 | search_params["deadline"] = arguments["deadline"] 217 | 218 | # Handle tag 219 | if "tag" in arguments: 220 | search_params["tag"] = arguments["tag"] 221 | 222 | # Handle area 223 | if "area" in arguments: 224 | search_params["area"] = arguments["area"] 225 | 226 | # Handle type 227 | if "type" in arguments: 228 | search_params["type"] = arguments["type"] 229 | 230 | todos = things.todos(**search_params) 231 | 232 | if not todos: 233 | return [types.TextContent(type="text", text="No matching todos found")] 234 | 235 | formatted_todos = [format_todo(todo) for todo in todos] 236 | return [types.TextContent(type="text", text="\n\n---\n\n".join(formatted_todos))] 237 | 238 | # Recent items 239 | elif name == "get-recent": 240 | if not arguments or "period" not in arguments: 241 | raise ValueError("Missing period parameter") 242 | 243 | period = arguments["period"] 244 | todos = things.last(period) 245 | 246 | if not todos: 247 | return [types.TextContent(type="text", 248 | text=f"No items found in the last {period}")] 249 | 250 | formatted_todos = [format_todo(todo) for todo in todos] 251 | return [types.TextContent(type="text", text="\n\n---\n\n".join(formatted_todos))] 252 | 253 | # Things direct AppleScript operations 254 | elif name == "add-todo": 255 | if not arguments or "title" not in arguments: 256 | raise ValueError("Missing title parameter") 257 | 258 | # We need to ensure any encoded characters are converted to actual spaces 259 | # This handles both '+' and '%20' that might be in the input 260 | 261 | # Clean up title and notes 262 | title = arguments["title"] 263 | if isinstance(title, str): 264 | title = title.replace("+", " ").replace("%20", " ") 265 | 266 | notes = arguments.get("notes") 267 | if isinstance(notes, str): 268 | notes = notes.replace("+", " ").replace("%20", " ") 269 | 270 | # Get other parameters 271 | when = arguments.get("when") 272 | tags = arguments.get("tags") 273 | list_title = arguments.get("list_title") 274 | 275 | # Import the AppleScript bridge 276 | from . import applescript_bridge 277 | 278 | # Prepare simplified parameters for the direct AppleScript approach 279 | # Only include parameters that our AppleScript bridge implementation supports 280 | simple_params = { 281 | "title": title, 282 | "notes": notes, 283 | "when": when, 284 | "tags": tags 285 | } 286 | 287 | # Remove None values 288 | simple_params = {k: v for k, v in simple_params.items() if v is not None} 289 | 290 | logger.info(f"Using direct AppleScript implementation to add todo: {title}") 291 | logger.info(f"Parameters: {simple_params}") 292 | 293 | # Try direct call without retry to capture actual errors 294 | try: 295 | logger.info("Calling add_todo_direct directly...") 296 | task_id = applescript_bridge.add_todo_direct(**simple_params) 297 | logger.info(f"Direct result: {task_id}") 298 | except Exception as e: 299 | logger.error(f"Exception in direct call: {str(e)}") 300 | return [types.TextContent(type="text", text=f"⚠️ Error: {str(e)}")] 301 | 302 | # If direct call didn't raise but returned falsy value, try with retry 303 | if not task_id: 304 | logger.info("Direct call failed, trying with retry...") 305 | try: 306 | task_id = retry_operation( 307 | lambda: applescript_bridge.add_todo_direct(**simple_params), 308 | operation_name="add-todo-direct", 309 | params=simple_params 310 | ) 311 | except Exception as e: 312 | logger.error(f"Exception in retry operation: {str(e)}") 313 | return [types.TextContent(type="text", text=f"⚠️ Error in retry: {str(e)}")] 314 | 315 | if not task_id: 316 | logger.error(f"Direct AppleScript creation failed for todo: {title}") 317 | return [types.TextContent(type="text", text=f"⚠️ Error: Failed to create todo: {title}")] 318 | 319 | return [types.TextContent(type="text", text=f"✅ Created new todo: {title} (ID: {task_id})")] 320 | 321 | elif name == "search-items": 322 | if not arguments or "query" not in arguments: 323 | raise ValueError("Missing query parameter") 324 | 325 | query = arguments["query"] 326 | params = {"query": query} 327 | 328 | url = url_scheme.search(query) 329 | success = retry_operation( 330 | lambda: url_scheme.execute_url(url), 331 | operation_name="search-items", 332 | params=params 333 | ) 334 | if not success: 335 | raise RuntimeError(f"Failed to search for: {query}") 336 | return [types.TextContent(type="text", text=f"Searching for '{query}'")] 337 | 338 | elif name == "add-project": 339 | if not arguments or "title" not in arguments: 340 | raise ValueError("Missing title parameter") 341 | 342 | # Prepare parameters 343 | params = { 344 | "title": arguments["title"], 345 | "notes": arguments.get("notes"), 346 | "when": arguments.get("when"), 347 | "deadline": arguments.get("deadline"), 348 | "tags": arguments.get("tags"), 349 | "area_id": arguments.get("area_id"), 350 | "area_title": arguments.get("area_title"), 351 | "todos": arguments.get("todos") 352 | } 353 | 354 | # Try X-Callback URL first 355 | try: 356 | success = retry_operation( 357 | lambda: url_scheme.execute_xcallback_url("add-project", params), 358 | operation_name="add-project", 359 | params=params 360 | ) 361 | if success: 362 | return [types.TextContent(type="text", text=f"✅ Created new project: {arguments['title']}")] 363 | except Exception as e: 364 | logger.warning(f"X-Callback add-project failed: {str(e)}, falling back to URL scheme") 365 | 366 | # Fall back to regular URL scheme 367 | url = url_scheme.add_project(**params) 368 | success = retry_operation( 369 | lambda: url_scheme.execute_url(url), 370 | operation_name="add-project", 371 | params=params 372 | ) 373 | 374 | if not success: 375 | raise RuntimeError(f"Failed to create project: {arguments['title']}") 376 | return [types.TextContent(type="text", text=f"Created new project: {arguments['title']}")] 377 | 378 | elif name == "update-todo": 379 | if not arguments or "id" not in arguments: 380 | raise ValueError("Missing id parameter") 381 | 382 | # Prepare parameters 383 | params = { 384 | "id": arguments["id"], 385 | "title": arguments.get("title"), 386 | "notes": arguments.get("notes"), 387 | "when": arguments.get("when"), 388 | "deadline": arguments.get("deadline"), 389 | "tags": arguments.get("tags"), 390 | "completed": arguments.get("completed"), 391 | "canceled": arguments.get("canceled") 392 | } 393 | 394 | from . import applescript_bridge 395 | import url_scheme 396 | 397 | # Special tag handling 398 | tag_update_needed = "tags" in arguments and arguments["tags"] is not None 399 | tag_only_update = tag_update_needed and all(v is None for k, v in params.items() 400 | if k not in ["id", "tags"]) 401 | 402 | # Log the tag update details 403 | if tag_update_needed: 404 | logger.info(f"Tag update needed: {arguments['tags']} for todo ID: {arguments['id']}") 405 | 406 | success = False 407 | 408 | # If this is a tag-only update, use hybrid approach that's proven to be most reliable 409 | if tag_only_update: 410 | logger.info(f"Using hybrid tag management approach for todo: {arguments['id']}") 411 | 412 | try: 413 | # Get the tags from the parameters 414 | todo_id = arguments['id'] 415 | tags = arguments['tags'] 416 | 417 | if not isinstance(tags, list) or not tags: 418 | logger.warning(f"Invalid tags format or empty tags list: {tags}") 419 | return False 420 | 421 | logger.info(f"Updating tags for todo {todo_id}: {tags}") 422 | 423 | # Step 1: Clear existing tags by setting empty tags 424 | clear_url = url_scheme.update_todo(id=todo_id, tags="") 425 | logger.info(f"Clearing existing tags: {clear_url}") 426 | clear_success = url_scheme.execute_url(clear_url) 427 | 428 | if not clear_success: 429 | logger.warning("Failed to clear existing tags") 430 | # Try to continue anyway 431 | 432 | # Wait for the clear operation to complete 433 | time.sleep(1) 434 | 435 | # Step 2: Add each tag using hybrid approach 436 | all_tags_added = True 437 | for tag in tags: 438 | # Make sure tag is a simple string 439 | tag_str = str(tag).strip() 440 | if not tag_str: 441 | continue 442 | 443 | # Use AppleScript to ensure the tag exists first 444 | logger.info(f"Ensuring tag exists: {tag_str}") 445 | script = f''' 446 | tell application "Things3" 447 | set tagExists to false 448 | 449 | repeat with t in tags 450 | if name of t is "{tag_str}" then 451 | set tagExists to true 452 | exit repeat 453 | end if 454 | end repeat 455 | 456 | if not tagExists then 457 | make new tag with properties {{name:"{tag_str}"}} 458 | return "Created tag: {tag_str}" 459 | else 460 | return "Tag already exists: {tag_str}" 461 | end if 462 | end tell 463 | ''' 464 | 465 | # Run AppleScript to create tag if needed 466 | result = run_applescript(script) 467 | if result: 468 | logger.info(result) 469 | else: 470 | logger.warning(f"Failed to ensure tag exists: {tag_str}") 471 | 472 | # Short delay after tag creation 473 | time.sleep(0.5) 474 | 475 | # Use add-tags parameter to apply the tag 476 | add_tag_url = url_scheme.update_todo(id=todo_id, add_tags=tag_str) 477 | logger.info(f"Adding tag '{tag_str}': {add_tag_url}") 478 | 479 | tag_success = url_scheme.execute_url(add_tag_url) 480 | if not tag_success: 481 | logger.warning(f"Failed to add tag: {tag_str}") 482 | all_tags_added = False 483 | 484 | # Add a small delay between tag operations 485 | time.sleep(1) 486 | 487 | if all_tags_added: 488 | logger.info(f"All tags successfully added to todo ID: {todo_id}") 489 | success = True 490 | else: 491 | logger.warning(f"Some tags failed to be added to todo ID: {todo_id}") 492 | # Consider it a partial success if we added at least some tags 493 | success = True 494 | 495 | except Exception as e: 496 | logger.warning(f"Hybrid tag update error: {str(e)}") 497 | 498 | # Approach 2: If URL scheme failed, try direct AppleScript 499 | if not success: 500 | logger.info(f"Falling back to AppleScript for tag update") 501 | success = retry_operation( 502 | lambda: applescript_bridge.update_todo_direct(**params), 503 | operation_name="update-todo-tags-direct", 504 | params=params 505 | ) 506 | else: 507 | # For regular updates, use the normal AppleScript approach 508 | logger.info(f"Using direct AppleScript implementation to update todo: {arguments['id']}") 509 | success = retry_operation( 510 | lambda: applescript_bridge.update_todo_direct(**params), 511 | operation_name="update-todo-direct", 512 | params=params 513 | ) 514 | 515 | # If the AppleScript update failed and there are tags to update, try URL scheme 516 | if not success and tag_update_needed: 517 | logger.info(f"AppleScript failed, trying URL scheme for tag update") 518 | url = url_scheme.update_todo(**params) 519 | success = retry_operation( 520 | lambda: url_scheme.execute_url(url), 521 | operation_name="update-todo-fallback", 522 | params=params 523 | ) 524 | 525 | if not success: 526 | logger.error(f"All update methods failed for todo with ID: {arguments['id']}") 527 | raise RuntimeError(f"Failed to update todo with ID: {arguments['id']}") 528 | 529 | return [types.TextContent(type="text", text=f"✅ Successfully updated todo with ID: {arguments['id']}")] 530 | 531 | elif name == "update-project": 532 | if not arguments or "id" not in arguments: 533 | raise ValueError("Missing id parameter") 534 | 535 | # Prepare parameters 536 | params = { 537 | "id": arguments["id"], 538 | "title": arguments.get("title"), 539 | "notes": arguments.get("notes"), 540 | "when": arguments.get("when"), 541 | "deadline": arguments.get("deadline"), 542 | "tags": arguments.get("tags"), 543 | "completed": arguments.get("completed"), 544 | "canceled": arguments.get("canceled") 545 | } 546 | 547 | # Try X-Callback URL first for better reliability 548 | try: 549 | success = retry_operation( 550 | lambda: url_scheme.execute_xcallback_url("update-project", params), 551 | operation_name="update-project", 552 | params=params 553 | ) 554 | if success: 555 | return [types.TextContent(type="text", text=f"✅ Successfully updated project with ID: {arguments['id']}")] 556 | except Exception as e: 557 | logger.warning(f"X-Callback update-project failed: {str(e)}, falling back to URL scheme") 558 | 559 | # Fall back to regular URL scheme 560 | url = url_scheme.update_project(**params) 561 | success = retry_operation( 562 | lambda: url_scheme.execute_url(url), 563 | operation_name="update-project", 564 | params=params 565 | ) 566 | 567 | if not success: 568 | raise RuntimeError(f"Failed to update project with ID: {arguments['id']}") 569 | return [types.TextContent(type="text", text=f"Successfully updated project with ID: {arguments['id']}")] 570 | 571 | elif name == "show-item": 572 | if not arguments or "id" not in arguments: 573 | raise ValueError("Missing id parameter") 574 | 575 | url = url_scheme.show( 576 | id=arguments["id"], 577 | query=arguments.get("query"), 578 | filter_tags=arguments.get("filter_tags") 579 | ) 580 | success = retry_operation(lambda: url_scheme.execute_url(url)) 581 | if not success: 582 | raise RuntimeError(f"Failed to show item with ID: {arguments['id']}") 583 | return [types.TextContent(type="text", text=f"Successfully opened item with ID: {arguments['id']}")] 584 | 585 | else: 586 | raise ValueError(f"Unknown tool: {name}") 587 | 588 | except Exception as e: 589 | logger.error(f"Error handling tool {name}: {str(e)}", exc_info=True) 590 | # Log full traceback for better debugging 591 | logger.debug(traceback.format_exc()) 592 | 593 | # Add to dead letter queue if appropriate 594 | if arguments: 595 | dead_letter_queue.add_failed_operation( 596 | name, 597 | arguments, 598 | str(e) 599 | ) 600 | 601 | return [types.TextContent(type="text", text=f"⚠️ Error: {str(e)}")] 602 | -------------------------------------------------------------------------------- /src/things_mcp/logging_config.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | Enhanced logging configuration for Things MCP server. 4 | Provides structured logging with multiple outputs and log levels. 5 | """ 6 | import logging 7 | import logging.handlers 8 | import os 9 | import json 10 | from datetime import datetime 11 | from pathlib import Path 12 | from typing import Dict, Any, Optional 13 | 14 | # Create logs directory if it doesn't exist 15 | LOGS_DIR = Path.home() / '.things-mcp' / 'logs' 16 | LOGS_DIR.mkdir(parents=True, exist_ok=True) 17 | 18 | class StructuredFormatter(logging.Formatter): 19 | """Custom formatter that outputs structured JSON logs for better analysis.""" 20 | 21 | def format(self, record: logging.LogRecord) -> str: 22 | log_data = { 23 | 'timestamp': datetime.utcnow().isoformat(), 24 | 'level': record.levelname, 25 | 'logger': record.name, 26 | 'message': record.getMessage(), 27 | 'module': record.module, 28 | 'function': record.funcName, 29 | 'line': record.lineno, 30 | } 31 | 32 | # Add any extra fields 33 | if hasattr(record, 'operation'): 34 | log_data['operation'] = record.operation 35 | if hasattr(record, 'duration'): 36 | log_data['duration'] = record.duration 37 | if hasattr(record, 'error_type'): 38 | log_data['error_type'] = record.error_type 39 | if hasattr(record, 'retry_count'): 40 | log_data['retry_count'] = record.retry_count 41 | 42 | # Add exception info if present 43 | if record.exc_info: 44 | log_data['exception'] = self.formatException(record.exc_info) 45 | 46 | return json.dumps(log_data) 47 | 48 | class OperationLogFilter(logging.Filter): 49 | """Filter to add operation context to log records.""" 50 | 51 | def __init__(self): 52 | super().__init__() 53 | self.operation_context = {} 54 | 55 | def set_operation_context(self, operation: str, **kwargs): 56 | """Set the current operation context.""" 57 | self.operation_context = { 58 | 'operation': operation, 59 | **kwargs 60 | } 61 | 62 | def clear_operation_context(self): 63 | """Clear the operation context.""" 64 | self.operation_context = {} 65 | 66 | def filter(self, record: logging.LogRecord) -> bool: 67 | # Add operation context to the record 68 | for key, value in self.operation_context.items(): 69 | setattr(record, key, value) 70 | return True 71 | 72 | # Global operation filter instance 73 | operation_filter = OperationLogFilter() 74 | 75 | def setup_logging( 76 | console_level: str = "INFO", 77 | file_level: str = "DEBUG", 78 | structured_logs: bool = True, 79 | max_bytes: int = 10 * 1024 * 1024, # 10MB 80 | backup_count: int = 5 81 | ) -> None: 82 | """ 83 | Configure comprehensive logging for the Things MCP server. 84 | 85 | Args: 86 | console_level: Log level for console output 87 | file_level: Log level for file output 88 | structured_logs: Whether to use structured JSON logging 89 | max_bytes: Maximum size of log files before rotation 90 | backup_count: Number of backup files to keep 91 | """ 92 | # Get the root logger 93 | root_logger = logging.getLogger() 94 | root_logger.setLevel(logging.DEBUG) # Capture everything, filter at handler level 95 | 96 | # Remove existing handlers 97 | root_logger.handlers.clear() 98 | 99 | # Console handler with simple formatting 100 | console_handler = logging.StreamHandler() 101 | console_handler.setLevel(getattr(logging, console_level.upper())) 102 | console_format = logging.Formatter( 103 | '%(asctime)s - %(name)s - %(levelname)s - %(message)s', 104 | datefmt='%Y-%m-%d %H:%M:%S' 105 | ) 106 | console_handler.setFormatter(console_format) 107 | console_handler.addFilter(operation_filter) 108 | root_logger.addHandler(console_handler) 109 | 110 | # File handlers with rotation 111 | if structured_logs: 112 | # Structured JSON logs for analysis 113 | json_file_handler = logging.handlers.RotatingFileHandler( 114 | LOGS_DIR / 'things_mcp_structured.json', 115 | maxBytes=max_bytes, 116 | backupCount=backup_count 117 | ) 118 | json_file_handler.setLevel(getattr(logging, file_level.upper())) 119 | json_file_handler.setFormatter(StructuredFormatter()) 120 | json_file_handler.addFilter(operation_filter) 121 | root_logger.addHandler(json_file_handler) 122 | 123 | # Human-readable file logs 124 | text_file_handler = logging.handlers.RotatingFileHandler( 125 | LOGS_DIR / 'things_mcp.log', 126 | maxBytes=max_bytes, 127 | backupCount=backup_count 128 | ) 129 | text_file_handler.setLevel(getattr(logging, file_level.upper())) 130 | text_format = logging.Formatter( 131 | '%(asctime)s - %(name)s - %(levelname)s - [%(funcName)s:%(lineno)d] - %(message)s', 132 | datefmt='%Y-%m-%d %H:%M:%S' 133 | ) 134 | text_file_handler.setFormatter(text_format) 135 | text_file_handler.addFilter(operation_filter) 136 | root_logger.addHandler(text_file_handler) 137 | 138 | # Error-only file handler 139 | error_file_handler = logging.handlers.RotatingFileHandler( 140 | LOGS_DIR / 'things_mcp_errors.log', 141 | maxBytes=max_bytes, 142 | backupCount=backup_count 143 | ) 144 | error_file_handler.setLevel(logging.ERROR) 145 | error_file_handler.setFormatter(text_format) 146 | error_file_handler.addFilter(operation_filter) 147 | root_logger.addHandler(error_file_handler) 148 | 149 | # Log the logging configuration 150 | logger = logging.getLogger(__name__) 151 | logger.info(f"Logging configured - Console: {console_level}, File: {file_level}, Structured: {structured_logs}") 152 | logger.info(f"Log files location: {LOGS_DIR}") 153 | 154 | def log_operation_start(operation: str, **kwargs) -> None: 155 | """Log the start of an operation and set context.""" 156 | operation_filter.set_operation_context(operation, **kwargs) 157 | logger = logging.getLogger(__name__) 158 | logger.info(f"Starting operation: {operation}", extra={'operation': operation, **kwargs}) 159 | 160 | def log_operation_end(operation: str, success: bool, duration: float = None, **kwargs) -> None: 161 | """Log the end of an operation.""" 162 | logger = logging.getLogger(__name__) 163 | extra = { 164 | 'operation': operation, 165 | 'success': success, 166 | **kwargs 167 | } 168 | if duration is not None: 169 | extra['duration'] = duration 170 | 171 | if success: 172 | logger.info(f"Operation completed: {operation}", extra=extra) 173 | else: 174 | logger.error(f"Operation failed: {operation}", extra=extra) 175 | 176 | operation_filter.clear_operation_context() 177 | 178 | def log_retry_attempt(operation: str, attempt: int, max_attempts: int, error: str) -> None: 179 | """Log a retry attempt.""" 180 | logger = logging.getLogger(__name__) 181 | logger.warning( 182 | f"Retry attempt {attempt}/{max_attempts} for {operation}: {error}", 183 | extra={ 184 | 'operation': operation, 185 | 'retry_count': attempt, 186 | 'max_attempts': max_attempts, 187 | 'error': error 188 | } 189 | ) 190 | 191 | def log_circuit_breaker_state(state: str, failure_count: int = None) -> None: 192 | """Log circuit breaker state changes.""" 193 | logger = logging.getLogger(__name__) 194 | extra = {'circuit_breaker_state': state} 195 | if failure_count is not None: 196 | extra['failure_count'] = failure_count 197 | 198 | logger.warning(f"Circuit breaker state changed to: {state}", extra=extra) 199 | 200 | def log_dead_letter_queue(operation: str, params: Dict[str, Any], error: str) -> None: 201 | """Log when an operation is added to the dead letter queue.""" 202 | logger = logging.getLogger(__name__) 203 | logger.error( 204 | f"Added to dead letter queue: {operation}", 205 | extra={ 206 | 'operation': operation, 207 | 'params': params, 208 | 'error': error, 209 | 'dlq': True 210 | } 211 | ) 212 | 213 | def get_logger(name: str) -> logging.Logger: 214 | """Get a logger instance with the given name.""" 215 | return logging.getLogger(name) 216 | 217 | # Initialize logging when module is imported 218 | setup_logging() -------------------------------------------------------------------------------- /src/things_mcp/mcp_tools.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | MCP tools configuration for Things integration with Windsurf. 4 | This ensures proper tool registration and naming for seamless integration. 5 | """ 6 | import mcp.types as types 7 | from typing import List 8 | 9 | def get_mcp_tools_list() -> List[types.Tool]: 10 | """ 11 | Return the list of MCP tools with consistent naming between registration and implementation. 12 | 13 | Uses consistent naming without prefixes to ensure proper tool functioning. 14 | """ 15 | return [ 16 | # Basic operations 17 | types.Tool( 18 | name="get-todos", 19 | description="Get todos from Things, optionally filtered by project", 20 | inputSchema={ 21 | "type": "object", 22 | "properties": { 23 | "project_uuid": { 24 | "type": "string", 25 | "description": "Optional UUID of a specific project to get todos from", 26 | }, 27 | "include_items": { 28 | "type": "boolean", 29 | "description": "Include checklist items", 30 | "default": True 31 | } 32 | }, 33 | "required": [], 34 | }, 35 | ), 36 | types.Tool( 37 | name="get-projects", 38 | description="Get all projects from Things", 39 | inputSchema={ 40 | "type": "object", 41 | "properties": { 42 | "include_items": { 43 | "type": "boolean", 44 | "description": "Include tasks within projects", 45 | "default": False 46 | } 47 | }, 48 | "required": [], 49 | }, 50 | ), 51 | types.Tool( 52 | name="get-areas", 53 | description="Get all areas from Things", 54 | inputSchema={ 55 | "type": "object", 56 | "properties": { 57 | "include_items": { 58 | "type": "boolean", 59 | "description": "Include projects and tasks within areas", 60 | "default": False 61 | } 62 | }, 63 | "required": [], 64 | }, 65 | ), 66 | 67 | # List views 68 | types.Tool( 69 | name="get-inbox", 70 | description="Get todos from Inbox", 71 | inputSchema={ 72 | "type": "object", 73 | "properties": {}, 74 | "required": [], 75 | }, 76 | ), 77 | types.Tool( 78 | name="get-today", 79 | description="Get todos due today", 80 | inputSchema={ 81 | "type": "object", 82 | "properties": {}, 83 | "required": [], 84 | }, 85 | ), 86 | types.Tool( 87 | name="get-upcoming", 88 | description="Get upcoming todos", 89 | inputSchema={ 90 | "type": "object", 91 | "properties": {}, 92 | "required": [], 93 | }, 94 | ), 95 | types.Tool( 96 | name="get-anytime", 97 | description="Get todos from Anytime list", 98 | inputSchema={ 99 | "type": "object", 100 | "properties": {}, 101 | "required": [], 102 | }, 103 | ), 104 | types.Tool( 105 | name="get-someday", 106 | description="Get todos from Someday list", 107 | inputSchema={ 108 | "type": "object", 109 | "properties": {}, 110 | "required": [], 111 | }, 112 | ), 113 | types.Tool( 114 | name="get-logbook", 115 | description="Get completed todos from Logbook, defaults to last 7 days", 116 | inputSchema={ 117 | "type": "object", 118 | "properties": { 119 | "period": { 120 | "type": "string", 121 | "description": "Time period to look back (e.g., '3d', '1w', '2m', '1y'). Defaults to '7d'", 122 | "pattern": "^\\d+[dwmy]$" 123 | }, 124 | "limit": { 125 | "type": "integer", 126 | "description": "Maximum number of entries to return. Defaults to 50", 127 | "minimum": 1, 128 | "maximum": 100 129 | } 130 | }, 131 | "required": [], 132 | }, 133 | ), 134 | types.Tool( 135 | name="get-trash", 136 | description="Get trashed todos", 137 | inputSchema={ 138 | "type": "object", 139 | "properties": {}, 140 | "required": [], 141 | }, 142 | ), 143 | 144 | # Tag operations 145 | types.Tool( 146 | name="get-tags", 147 | description="Get all tags", 148 | inputSchema={ 149 | "type": "object", 150 | "properties": { 151 | "include_items": { 152 | "type": "boolean", 153 | "description": "Include items tagged with each tag", 154 | "default": False 155 | } 156 | }, 157 | "required": [], 158 | }, 159 | ), 160 | types.Tool( 161 | name="get-tagged-items", 162 | description="Get items with a specific tag", 163 | inputSchema={ 164 | "type": "object", 165 | "properties": { 166 | "tag": { 167 | "type": "string", 168 | "description": "Tag title to filter by" 169 | } 170 | }, 171 | "required": ["tag"], 172 | }, 173 | ), 174 | 175 | # Search operations 176 | types.Tool( 177 | name="search-todos", 178 | description="Search todos by title or notes", 179 | inputSchema={ 180 | "type": "object", 181 | "properties": { 182 | "query": { 183 | "type": "string", 184 | "description": "Search term to look for in todo titles and notes", 185 | }, 186 | }, 187 | "required": ["query"], 188 | }, 189 | ), 190 | types.Tool( 191 | name="search-advanced", 192 | description="Advanced todo search with multiple filters", 193 | inputSchema={ 194 | "type": "object", 195 | "properties": { 196 | "status": { 197 | "type": "string", 198 | "enum": ["incomplete", "completed", "canceled"], 199 | "description": "Filter by todo status" 200 | }, 201 | "start_date": { 202 | "type": "string", 203 | "description": "Filter by start date (YYYY-MM-DD)" 204 | }, 205 | "deadline": { 206 | "type": "string", 207 | "description": "Filter by deadline (YYYY-MM-DD)" 208 | }, 209 | "tag": { 210 | "type": "string", 211 | "description": "Filter by tag" 212 | }, 213 | "area": { 214 | "type": "string", 215 | "description": "Filter by area UUID" 216 | }, 217 | "type": { 218 | "type": "string", 219 | "enum": ["to-do", "project", "heading"], 220 | "description": "Filter by item type" 221 | } 222 | }, 223 | "required": [], 224 | }, 225 | ), 226 | 227 | # Recent items 228 | types.Tool( 229 | name="get-recent", 230 | description="Get recently created items", 231 | inputSchema={ 232 | "type": "object", 233 | "properties": { 234 | "period": { 235 | "type": "string", 236 | "description": "Time period (e.g., '3d', '1w', '2m', '1y')", 237 | "pattern": "^\\d+[dwmy]$" 238 | } 239 | }, 240 | "required": ["period"], 241 | }, 242 | ), 243 | 244 | # Things URL Scheme tools 245 | types.Tool( 246 | name="add-todo", 247 | description="Create a new todo in Things", 248 | inputSchema={ 249 | "type": "object", 250 | "properties": { 251 | "title": { 252 | "type": "string", 253 | "description": "Title of the todo" 254 | }, 255 | "notes": { 256 | "type": "string", 257 | "description": "Notes for the todo" 258 | }, 259 | "when": { 260 | "type": "string", 261 | "description": "When to schedule the todo (today, tomorrow, evening, anytime, someday, or YYYY-MM-DD)" 262 | }, 263 | "deadline": { 264 | "type": "string", 265 | "description": "Deadline for the todo (YYYY-MM-DD)" 266 | }, 267 | "tags": { 268 | "type": "array", 269 | "items": {"type": "string"}, 270 | "description": "Tags to apply to the todo" 271 | }, 272 | "checklist_items": { 273 | "type": "array", 274 | "items": {"type": "string"}, 275 | "description": "Checklist items to add" 276 | }, 277 | "list_id": { 278 | "type": "string", 279 | "description": "ID of project/area to add to" 280 | }, 281 | "list_title": { 282 | "type": "string", 283 | "description": "Title of project/area to add to" 284 | }, 285 | "heading": { 286 | "type": "string", 287 | "description": "Heading to add under" 288 | } 289 | }, 290 | "required": ["title"] 291 | } 292 | ), 293 | 294 | types.Tool( 295 | name="add-project", 296 | description="Create a new project in Things", 297 | inputSchema={ 298 | "type": "object", 299 | "properties": { 300 | "title": { 301 | "type": "string", 302 | "description": "Title of the project" 303 | }, 304 | "notes": { 305 | "type": "string", 306 | "description": "Notes for the project" 307 | }, 308 | "when": { 309 | "type": "string", 310 | "description": "When to schedule the project" 311 | }, 312 | "deadline": { 313 | "type": "string", 314 | "description": "Deadline for the project" 315 | }, 316 | "tags": { 317 | "type": "array", 318 | "items": {"type": "string"}, 319 | "description": "Tags to apply to the project" 320 | }, 321 | "area_id": { 322 | "type": "string", 323 | "description": "ID of area to add to" 324 | }, 325 | "area_title": { 326 | "type": "string", 327 | "description": "Title of area to add to" 328 | }, 329 | "todos": { 330 | "type": "array", 331 | "items": {"type": "string"}, 332 | "description": "Initial todos to create in the project" 333 | } 334 | }, 335 | "required": ["title"] 336 | } 337 | ), 338 | 339 | types.Tool( 340 | name="update-todo", 341 | description="Update an existing todo in Things", 342 | inputSchema={ 343 | "type": "object", 344 | "properties": { 345 | "id": { 346 | "type": "string", 347 | "description": "ID of the todo to update" 348 | }, 349 | "title": { 350 | "type": "string", 351 | "description": "New title" 352 | }, 353 | "notes": { 354 | "type": "string", 355 | "description": "New notes" 356 | }, 357 | "when": { 358 | "type": "string", 359 | "description": "New schedule" 360 | }, 361 | "deadline": { 362 | "type": "string", 363 | "description": "New deadline" 364 | }, 365 | "tags": { 366 | "type": "array", 367 | "items": {"type": "string"}, 368 | "description": "New tags" 369 | }, 370 | "completed": { 371 | "type": "boolean", 372 | "description": "Mark as completed" 373 | }, 374 | "canceled": { 375 | "type": "boolean", 376 | "description": "Mark as canceled" 377 | } 378 | }, 379 | "required": ["id"] 380 | } 381 | ), 382 | 383 | types.Tool( 384 | name="update-project", 385 | description="Update an existing project in Things", 386 | inputSchema={ 387 | "type": "object", 388 | "properties": { 389 | "id": { 390 | "type": "string", 391 | "description": "ID of the project to update" 392 | }, 393 | "title": { 394 | "type": "string", 395 | "description": "New title" 396 | }, 397 | "notes": { 398 | "type": "string", 399 | "description": "New notes" 400 | }, 401 | "when": { 402 | "type": "string", 403 | "description": "New schedule" 404 | }, 405 | "deadline": { 406 | "type": "string", 407 | "description": "New deadline" 408 | }, 409 | "tags": { 410 | "type": "array", 411 | "items": {"type": "string"}, 412 | "description": "New tags" 413 | }, 414 | "completed": { 415 | "type": "boolean", 416 | "description": "Mark as completed" 417 | }, 418 | "canceled": { 419 | "type": "boolean", 420 | "description": "Mark as canceled" 421 | } 422 | }, 423 | "required": ["id"] 424 | } 425 | ), 426 | 427 | types.Tool( 428 | name="search-items", 429 | description="Search for items in Things", 430 | inputSchema={ 431 | "type": "object", 432 | "properties": { 433 | "query": { 434 | "type": "string", 435 | "description": "Search query" 436 | } 437 | }, 438 | "required": ["query"] 439 | } 440 | ), 441 | 442 | types.Tool( 443 | name="show-item", 444 | description="Show a specific item or list in Things", 445 | inputSchema={ 446 | "type": "object", 447 | "properties": { 448 | "id": { 449 | "type": "string", 450 | "description": "ID of item to show, or one of: inbox, today, upcoming, anytime, someday, logbook" 451 | }, 452 | "query": { 453 | "type": "string", 454 | "description": "Optional query to filter by" 455 | }, 456 | "filter_tags": { 457 | "type": "array", 458 | "items": {"type": "string"}, 459 | "description": "Optional tags to filter by" 460 | } 461 | }, 462 | "required": ["id"] 463 | } 464 | ) 465 | ] 466 | -------------------------------------------------------------------------------- /src/things_mcp/simple_server.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | Simplified Things MCP Server with essential reliability features. 4 | Focuses on practicality over enterprise patterns. 5 | """ 6 | import logging 7 | import time 8 | import things 9 | from typing import Dict, Any, Optional, List 10 | from functools import lru_cache 11 | from datetime import datetime, timedelta 12 | 13 | from mcp.server.fastmcp import FastMCP 14 | import mcp.types as types 15 | 16 | # Import our minimal supporting modules 17 | from .formatters import format_todo, format_project, format_area, format_tag 18 | from .applescript_bridge import add_todo_direct, update_todo_direct 19 | from .simple_url_scheme import add_todo, add_project, update_todo, update_project, show, search, launch_things 20 | from .config import get_things_auth_token 21 | from .tag_handler import ensure_tags_exist 22 | 23 | # Simple logging setup 24 | logging.basicConfig( 25 | level=logging.INFO, 26 | format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' 27 | ) 28 | logger = logging.getLogger(__name__) 29 | 30 | # Create the FastMCP server 31 | mcp = FastMCP( 32 | "Things", 33 | description="Interact with the Things task management app", 34 | version="0.2.0" 35 | ) 36 | 37 | # Simple retry decorator 38 | def retry(max_attempts=3, delay=1.0): 39 | """Simple retry decorator with fixed delay.""" 40 | def decorator(func): 41 | def wrapper(*args, **kwargs): 42 | last_error = None 43 | for attempt in range(max_attempts): 44 | try: 45 | return func(*args, **kwargs) 46 | except Exception as e: 47 | last_error = e 48 | if attempt < max_attempts - 1: 49 | logger.warning(f"Attempt {attempt + 1} failed: {str(e)}. Retrying...") 50 | time.sleep(delay) 51 | else: 52 | logger.error(f"All {max_attempts} attempts failed: {str(e)}") 53 | raise last_error 54 | return wrapper 55 | return decorator 56 | 57 | # Simple cache with TTL 58 | class SimpleCache: 59 | def __init__(self): 60 | self.cache = {} 61 | self.timestamps = {} 62 | 63 | def get(self, key, ttl_seconds=300): 64 | """Get value from cache if not expired.""" 65 | if key in self.cache: 66 | if datetime.now() - self.timestamps[key] < timedelta(seconds=ttl_seconds): 67 | return self.cache[key] 68 | else: 69 | # Expired, remove it 70 | del self.cache[key] 71 | del self.timestamps[key] 72 | return None 73 | 74 | def set(self, key, value): 75 | """Set value in cache.""" 76 | self.cache[key] = value 77 | self.timestamps[key] = datetime.now() 78 | 79 | def invalidate(self, pattern=None): 80 | """Clear cache entries matching pattern or all if pattern is None.""" 81 | if pattern is None: 82 | self.cache.clear() 83 | self.timestamps.clear() 84 | else: 85 | keys_to_remove = [k for k in self.cache.keys() if pattern in k] 86 | for key in keys_to_remove: 87 | del self.cache[key] 88 | del self.timestamps[key] 89 | 90 | # Global cache instance 91 | cache = SimpleCache() 92 | 93 | # Helper function to ensure Things is running 94 | def ensure_things_running(): 95 | """Make sure Things app is running.""" 96 | try: 97 | # Quick check if Things is responsive 98 | things.inbox() 99 | return True 100 | except: 101 | logger.info("Things not responding, attempting to launch...") 102 | if launch_things(): 103 | time.sleep(2) # Give it time to start 104 | return True 105 | return False 106 | 107 | # LIST VIEWS 108 | 109 | @mcp.tool(name="get-inbox") 110 | def get_inbox() -> str: 111 | """Get todos from Inbox""" 112 | # Check cache first 113 | cached_result = cache.get("inbox", ttl_seconds=30) 114 | if cached_result is not None: 115 | return cached_result 116 | 117 | todos = things.inbox() 118 | 119 | if not todos: 120 | result = "No items found in Inbox" 121 | else: 122 | formatted_todos = [format_todo(todo) for todo in todos] 123 | result = "\n\n---\n\n".join(formatted_todos) 124 | 125 | cache.set("inbox", result) 126 | return result 127 | 128 | @mcp.tool(name="get-today") 129 | def get_today() -> str: 130 | """Get todos due today""" 131 | cached_result = cache.get("today", ttl_seconds=30) 132 | if cached_result is not None: 133 | return cached_result 134 | 135 | todos = things.today() 136 | 137 | if not todos: 138 | result = "No items due today" 139 | else: 140 | formatted_todos = [format_todo(todo) for todo in todos] 141 | result = "\n\n---\n\n".join(formatted_todos) 142 | 143 | cache.set("today", result) 144 | return result 145 | 146 | @mcp.tool(name="get-upcoming") 147 | def get_upcoming() -> str: 148 | """Get upcoming todos""" 149 | cached_result = cache.get("upcoming", ttl_seconds=60) 150 | if cached_result is not None: 151 | return cached_result 152 | 153 | todos = things.upcoming() 154 | 155 | if not todos: 156 | result = "No upcoming items" 157 | else: 158 | formatted_todos = [format_todo(todo) for todo in todos] 159 | result = "\n\n---\n\n".join(formatted_todos) 160 | 161 | cache.set("upcoming", result) 162 | return result 163 | 164 | @mcp.tool(name="get-anytime") 165 | def get_anytime() -> str: 166 | """Get todos from Anytime list""" 167 | cached_result = cache.get("anytime", ttl_seconds=300) 168 | if cached_result is not None: 169 | return cached_result 170 | 171 | todos = things.anytime() 172 | 173 | if not todos: 174 | result = "No items in Anytime list" 175 | else: 176 | formatted_todos = [format_todo(todo) for todo in todos] 177 | result = "\n\n---\n\n".join(formatted_todos) 178 | 179 | cache.set("anytime", result) 180 | return result 181 | 182 | @mcp.tool(name="get-someday") 183 | def get_someday() -> str: 184 | """Get todos from Someday list""" 185 | cached_result = cache.get("someday", ttl_seconds=300) 186 | if cached_result is not None: 187 | return cached_result 188 | 189 | todos = things.someday() 190 | 191 | if not todos: 192 | result = "No items in Someday list" 193 | else: 194 | formatted_todos = [format_todo(todo) for todo in todos] 195 | result = "\n\n---\n\n".join(formatted_todos) 196 | 197 | cache.set("someday", result) 198 | return result 199 | 200 | @mcp.tool(name="get-logbook") 201 | def get_logbook(period: str = "7d", limit: int = 50) -> str: 202 | """Get completed todos from Logbook""" 203 | cache_key = f"logbook_{period}_{limit}" 204 | cached_result = cache.get(cache_key, ttl_seconds=300) 205 | if cached_result is not None: 206 | return cached_result 207 | 208 | todos = things.last(period, status='completed') 209 | 210 | if not todos: 211 | result = "No completed items found" 212 | else: 213 | if len(todos) > limit: 214 | todos = todos[:limit] 215 | formatted_todos = [format_todo(todo) for todo in todos] 216 | result = "\n\n---\n\n".join(formatted_todos) 217 | 218 | cache.set(cache_key, result) 219 | return result 220 | 221 | @mcp.tool(name="get-trash") 222 | def get_trash() -> str: 223 | """Get trashed todos""" 224 | todos = things.trash() 225 | 226 | if not todos: 227 | return "No items in trash" 228 | 229 | formatted_todos = [format_todo(todo) for todo in todos] 230 | return "\n\n---\n\n".join(formatted_todos) 231 | 232 | # BASIC TODO OPERATIONS 233 | 234 | @mcp.tool(name="get-todos") 235 | def get_todos(project_uuid: Optional[str] = None, include_items: bool = True) -> str: 236 | """Get todos, optionally filtered by project""" 237 | if project_uuid: 238 | project = things.get(project_uuid) 239 | if not project or project.get('type') != 'project': 240 | return f"Error: Invalid project UUID '{project_uuid}'" 241 | 242 | todos = things.todos(project=project_uuid, start=None) 243 | 244 | if not todos: 245 | return "No todos found" 246 | 247 | formatted_todos = [format_todo(todo) for todo in todos] 248 | return "\n\n---\n\n".join(formatted_todos) 249 | 250 | @mcp.tool(name="get-projects") 251 | def get_projects(include_items: bool = False) -> str: 252 | """Get all projects from Things""" 253 | cached_result = cache.get(f"projects_{include_items}", ttl_seconds=300) 254 | if cached_result is not None: 255 | return cached_result 256 | 257 | projects = things.projects() 258 | 259 | if not projects: 260 | result = "No projects found" 261 | else: 262 | formatted_projects = [format_project(project, include_items) for project in projects] 263 | result = "\n\n---\n\n".join(formatted_projects) 264 | 265 | cache.set(f"projects_{include_items}", result) 266 | return result 267 | 268 | @mcp.tool(name="get-areas") 269 | def get_areas(include_items: bool = False) -> str: 270 | """Get all areas from Things""" 271 | cached_result = cache.get(f"areas_{include_items}", ttl_seconds=600) 272 | if cached_result is not None: 273 | return cached_result 274 | 275 | areas = things.areas() 276 | 277 | if not areas: 278 | result = "No areas found" 279 | else: 280 | formatted_areas = [format_area(area, include_items) for area in areas] 281 | result = "\n\n---\n\n".join(formatted_areas) 282 | 283 | cache.set(f"areas_{include_items}", result) 284 | return result 285 | 286 | # TAG OPERATIONS 287 | 288 | @mcp.tool(name="get-tags") 289 | def get_tags(include_items: bool = False) -> str: 290 | """Get all tags""" 291 | cached_result = cache.get(f"tags_{include_items}", ttl_seconds=600) 292 | if cached_result is not None: 293 | return cached_result 294 | 295 | tags = things.tags() 296 | 297 | if not tags: 298 | result = "No tags found" 299 | else: 300 | formatted_tags = [format_tag(tag, include_items) for tag in tags] 301 | result = "\n\n---\n\n".join(formatted_tags) 302 | 303 | cache.set(f"tags_{include_items}", result) 304 | return result 305 | 306 | @mcp.tool(name="get-tagged-items") 307 | def get_tagged_items(tag: str) -> str: 308 | """Get items with a specific tag""" 309 | todos = things.todos(tag=tag) 310 | 311 | if not todos: 312 | return f"No items found with tag '{tag}'" 313 | 314 | formatted_todos = [format_todo(todo) for todo in todos] 315 | return "\n\n---\n\n".join(formatted_todos) 316 | 317 | # SEARCH OPERATIONS 318 | 319 | @mcp.tool(name="search-todos") 320 | def search_todos(query: str) -> str: 321 | """Search todos by title or notes""" 322 | todos = things.search(query) 323 | 324 | if not todos: 325 | return f"No todos found matching '{query}'" 326 | 327 | formatted_todos = [format_todo(todo) for todo in todos] 328 | return "\n\n---\n\n".join(formatted_todos) 329 | 330 | @mcp.tool(name="search-advanced") 331 | def search_advanced( 332 | status: Optional[str] = None, 333 | start_date: Optional[str] = None, 334 | deadline: Optional[str] = None, 335 | tag: Optional[str] = None, 336 | area: Optional[str] = None, 337 | type: Optional[str] = None 338 | ) -> str: 339 | """Advanced todo search with multiple filters""" 340 | kwargs = {} 341 | if status: 342 | kwargs['status'] = status 343 | if deadline: 344 | kwargs['deadline'] = deadline 345 | if start_date: 346 | kwargs['start'] = start_date 347 | if tag: 348 | kwargs['tag'] = tag 349 | if area: 350 | kwargs['area'] = area 351 | if type: 352 | kwargs['type'] = type 353 | 354 | try: 355 | todos = things.todos(**kwargs) 356 | 357 | if not todos: 358 | return "No items found matching your search criteria" 359 | 360 | formatted_todos = [format_todo(todo) for todo in todos] 361 | return "\n\n---\n\n".join(formatted_todos) 362 | except Exception as e: 363 | return f"Error in advanced search: {str(e)}" 364 | 365 | # MODIFICATION OPERATIONS 366 | 367 | @mcp.tool(name="add-todo") 368 | @retry(max_attempts=3) 369 | def add_task( 370 | title: str, 371 | notes: Optional[str] = None, 372 | when: Optional[str] = None, 373 | deadline: Optional[str] = None, 374 | tags: Optional[List[str]] = None, 375 | checklist_items: Optional[List[str]] = None, 376 | list_id: Optional[str] = None, 377 | list_title: Optional[str] = None, 378 | heading: Optional[str] = None 379 | ) -> str: 380 | """Create a new todo in Things""" 381 | # Ensure Things is running 382 | if not ensure_things_running(): 383 | return "Error: Unable to connect to Things app" 384 | 385 | # Ensure tags exist before using them 386 | if tags: 387 | logger.info(f"Ensuring tags exist: {tags}") 388 | if not ensure_tags_exist(tags): 389 | logger.warning("Failed to ensure all tags exist, but continuing anyway") 390 | 391 | # Use URL scheme for creation as it supports more parameters 392 | # According to Things URL scheme docs, some features only work via URL scheme 393 | try: 394 | # The URL scheme uses 'list' parameter for project/area name 395 | result = add_todo( 396 | title=title, 397 | notes=notes, 398 | when=when, 399 | deadline=deadline, 400 | tags=tags, 401 | checklist_items=checklist_items, 402 | list_id=list_id, 403 | list=list_title, # This is the correct parameter name for project/area 404 | heading=heading 405 | ) 406 | 407 | if result: 408 | # Invalidate relevant caches 409 | cache.invalidate("inbox") 410 | cache.invalidate("today") 411 | cache.invalidate("upcoming") 412 | 413 | return f"✅ Created todo: {title}" 414 | else: 415 | return f"❌ Failed to create todo: {title}" 416 | except Exception as e: 417 | logger.error(f"Error creating todo: {str(e)}") 418 | return f"❌ Error creating todo: {str(e)}" 419 | 420 | @mcp.tool(name="add-project") 421 | @retry(max_attempts=3) 422 | def add_new_project( 423 | title: str, 424 | notes: Optional[str] = None, 425 | when: Optional[str] = None, 426 | deadline: Optional[str] = None, 427 | tags: Optional[List[str]] = None, 428 | area_id: Optional[str] = None, 429 | area_title: Optional[str] = None, 430 | todos: Optional[List[str]] = None 431 | ) -> str: 432 | """Create a new project in Things""" 433 | if not ensure_things_running(): 434 | return "Error: Unable to connect to Things app" 435 | 436 | # Ensure tags exist before using them 437 | if tags: 438 | logger.info(f"Ensuring tags exist for project: {tags}") 439 | if not ensure_tags_exist(tags): 440 | logger.warning("Failed to ensure all tags exist, but continuing anyway") 441 | 442 | try: 443 | result = add_project( 444 | title=title, 445 | notes=notes, 446 | when=when, 447 | deadline=deadline, 448 | tags=tags, 449 | area_id=area_id, 450 | area=area_title, # Correct parameter name for area 451 | todos=todos 452 | ) 453 | 454 | if result: 455 | cache.invalidate("projects") 456 | return f"✅ Created project: {title}" 457 | else: 458 | return f"❌ Failed to create project: {title}" 459 | except Exception as e: 460 | logger.error(f"Error creating project: {str(e)}") 461 | return f"❌ Error creating project: {str(e)}" 462 | 463 | @mcp.tool(name="update-todo") 464 | @retry(max_attempts=3) 465 | def update_task( 466 | id: str, 467 | title: Optional[str] = None, 468 | notes: Optional[str] = None, 469 | when: Optional[str] = None, 470 | deadline: Optional[str] = None, 471 | tags: Optional[List[str]] = None, 472 | completed: Optional[bool] = None, 473 | canceled: Optional[bool] = None 474 | ) -> str: 475 | """Update an existing todo in Things""" 476 | if not ensure_things_running(): 477 | return "Error: Unable to connect to Things app" 478 | 479 | # Ensure tags exist before using them 480 | if tags: 481 | logger.info(f"Ensuring tags exist for update: {tags}") 482 | if not ensure_tags_exist(tags): 483 | logger.warning("Failed to ensure all tags exist, but continuing anyway") 484 | 485 | # Use URL scheme for updates as per Things documentation 486 | try: 487 | result = update_todo( 488 | id=id, 489 | title=title, 490 | notes=notes, 491 | when=when, 492 | deadline=deadline, 493 | tags=tags, 494 | completed=completed, 495 | canceled=canceled 496 | ) 497 | 498 | if result: 499 | cache.invalidate(None) # Clear all caches 500 | return f"✅ Updated todo: {id}" 501 | else: 502 | return f"❌ Failed to update todo: {id}" 503 | except Exception as e: 504 | logger.error(f"Error updating todo: {str(e)}") 505 | return f"❌ Error updating todo: {str(e)}" 506 | 507 | @mcp.tool(name="delete-todo") 508 | @retry(max_attempts=3) 509 | def delete_todo(id: str) -> str: 510 | """Delete a todo by moving it to trash""" 511 | if not ensure_things_running(): 512 | return "Error: Unable to connect to Things app" 513 | 514 | try: 515 | # In Things, "deleting" means canceling 516 | result = update_todo( 517 | id=id, 518 | canceled=True 519 | ) 520 | 521 | if result: 522 | cache.invalidate(None) # Clear all caches 523 | return f"✅ Deleted todo (moved to trash): {id}" 524 | else: 525 | return f"❌ Failed to delete todo: {id}" 526 | except Exception as e: 527 | logger.error(f"Error deleting todo: {str(e)}") 528 | return f"❌ Error deleting todo: {str(e)}" 529 | 530 | @mcp.tool(name="update-project") 531 | @retry(max_attempts=3) 532 | def update_existing_project( 533 | id: str, 534 | title: Optional[str] = None, 535 | notes: Optional[str] = None, 536 | when: Optional[str] = None, 537 | deadline: Optional[str] = None, 538 | tags: Optional[List[str]] = None, 539 | completed: Optional[bool] = None, 540 | canceled: Optional[bool] = None 541 | ) -> str: 542 | """Update an existing project in Things""" 543 | if not ensure_things_running(): 544 | return "Error: Unable to connect to Things app" 545 | 546 | # Ensure tags exist before using them 547 | if tags: 548 | logger.info(f"Ensuring tags exist for project update: {tags}") 549 | if not ensure_tags_exist(tags): 550 | logger.warning("Failed to ensure all tags exist, but continuing anyway") 551 | 552 | try: 553 | result = update_project( 554 | id=id, 555 | title=title, 556 | notes=notes, 557 | when=when, 558 | deadline=deadline, 559 | tags=tags, 560 | completed=completed, 561 | canceled=canceled 562 | ) 563 | 564 | if result: 565 | cache.invalidate("projects") 566 | return f"✅ Updated project: {id}" 567 | else: 568 | return f"❌ Failed to update project: {id}" 569 | except Exception as e: 570 | logger.error(f"Error updating project: {str(e)}") 571 | return f"❌ Error updating project: {str(e)}" 572 | 573 | @mcp.tool(name="delete-project") 574 | @retry(max_attempts=3) 575 | def delete_project(id: str) -> str: 576 | """Delete a project by moving it to trash""" 577 | if not ensure_things_running(): 578 | return "Error: Unable to connect to Things app" 579 | 580 | try: 581 | # In Things, "deleting" means canceling and moving to trash 582 | # First cancel the project 583 | result = update_project( 584 | id=id, 585 | canceled=True 586 | ) 587 | 588 | if result: 589 | cache.invalidate("projects") 590 | cache.invalidate("trash") 591 | return f"✅ Deleted project (moved to trash): {id}" 592 | else: 593 | return f"❌ Failed to delete project: {id}" 594 | except Exception as e: 595 | logger.error(f"Error deleting project: {str(e)}") 596 | return f"❌ Error deleting project: {str(e)}" 597 | 598 | @mcp.tool(name="show-item") 599 | def show_item( 600 | id: str, 601 | query: Optional[str] = None, 602 | filter_tags: Optional[List[str]] = None 603 | ) -> str: 604 | """Show a specific item or list in Things""" 605 | if not ensure_things_running(): 606 | return "Error: Unable to connect to Things app" 607 | 608 | try: 609 | result = show( 610 | id=id, 611 | query=query, 612 | filter_tags=filter_tags 613 | ) 614 | 615 | if result: 616 | return f"✅ Opened '{id}' in Things" 617 | else: 618 | return f"❌ Failed to show item: {id}" 619 | except Exception as e: 620 | logger.error(f"Error showing item: {str(e)}") 621 | return f"❌ Error showing item: {str(e)}" 622 | 623 | @mcp.tool(name="search-items") 624 | def search_all_items(query: str) -> str: 625 | """Search for items in Things""" 626 | if not ensure_things_running(): 627 | return "Error: Unable to connect to Things app" 628 | 629 | try: 630 | result = search(query=query) 631 | 632 | if result: 633 | return f"✅ Searching for '{query}' in Things" 634 | else: 635 | return f"❌ Failed to search for: {query}" 636 | except Exception as e: 637 | logger.error(f"Error searching: {str(e)}") 638 | return f"❌ Error searching: {str(e)}" 639 | 640 | @mcp.tool(name="get-recent") 641 | def get_recent(period: str) -> str: 642 | """Get recently created items""" 643 | if not period or not any(period.endswith(unit) for unit in ['d', 'w', 'm', 'y']): 644 | return "Error: Period must be in format '3d', '1w', '2m', '1y'" 645 | 646 | try: 647 | items = things.last(period) 648 | 649 | if not items: 650 | return f"No items found in the last {period}" 651 | 652 | formatted_items = [] 653 | for item in items: 654 | if item.get('type') == 'to-do': 655 | formatted_items.append(format_todo(item)) 656 | elif item.get('type') == 'project': 657 | formatted_items.append(format_project(item, include_items=False)) 658 | 659 | return "\n\n---\n\n".join(formatted_items) 660 | except Exception as e: 661 | logger.error(f"Error getting recent items: {str(e)}") 662 | return f"Error getting recent items: {str(e)}" 663 | 664 | # Main entry point 665 | def run_simple_things_server(): 666 | """Run the simplified Things MCP server""" 667 | logger.info("Starting simplified Things MCP server...") 668 | 669 | # Check if Things is available at startup 670 | if ensure_things_running(): 671 | logger.info("Things app is ready") 672 | else: 673 | logger.warning("Things app is not available - will retry when needed") 674 | 675 | # Check for auth token 676 | token = get_things_auth_token() 677 | if not token: 678 | logger.warning("No Things auth token configured. Run 'python configure_token.py' to set it up.") 679 | 680 | # Run the server 681 | mcp.run() 682 | 683 | # Make mcp available as 'server' for MCP dev command 684 | server = mcp 685 | 686 | if __name__ == "__main__": 687 | run_simple_things_server() -------------------------------------------------------------------------------- /src/things_mcp/simple_url_scheme.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | Simplified URL scheme implementation for Things. 4 | Based on https://culturedcode.com/things/support/articles/2803573/ 5 | """ 6 | import urllib.parse 7 | import webbrowser 8 | import subprocess 9 | import time 10 | import logging 11 | from typing import Optional, Dict, Any, List 12 | 13 | logger = logging.getLogger(__name__) 14 | 15 | def launch_things() -> bool: 16 | """Launch Things app if not already running.""" 17 | try: 18 | # Check if running 19 | result = subprocess.run( 20 | ['osascript', '-e', 'tell application "System Events" to (name of processes) contains "Things3"'], 21 | capture_output=True, 22 | text=True, 23 | check=False 24 | ) 25 | 26 | if result.stdout.strip().lower() == 'true': 27 | return True 28 | 29 | # Launch Things 30 | subprocess.run(['open', '-a', 'Things3'], capture_output=True, check=False) 31 | time.sleep(2) # Give it time to start 32 | return True 33 | except Exception as e: 34 | logger.error(f"Error launching Things: {str(e)}") 35 | return False 36 | 37 | def execute_url(url: str) -> bool: 38 | """Execute a Things URL.""" 39 | try: 40 | logger.debug(f"Executing URL: {url}") 41 | 42 | # Ensure Things is running 43 | launch_things() 44 | 45 | # Execute the URL 46 | result = webbrowser.open(url) 47 | 48 | # Small delay to let Things process 49 | time.sleep(0.5) 50 | 51 | return result 52 | except Exception as e: 53 | logger.error(f"Failed to execute URL: {str(e)}") 54 | return False 55 | 56 | def construct_url(command: str, params: Dict[str, Any]) -> str: 57 | """Construct a Things URL from command and parameters.""" 58 | # Start with base URL 59 | url = f"things:///{command}" 60 | 61 | # Get authentication token if available 62 | try: 63 | from . import config 64 | token = config.get_things_auth_token() 65 | if token: 66 | params['auth-token'] = token 67 | logger.debug(f"Added authentication token to {command} URL") 68 | else: 69 | logger.warning(f"No authentication token configured for {command} URL") 70 | except Exception as e: 71 | logger.error(f"Failed to get authentication token: {str(e)}") 72 | # Try environment variable as fallback 73 | import os 74 | token = os.environ.get('THINGS_AUTH_TOKEN') 75 | if token: 76 | params['auth-token'] = token 77 | logger.debug(f"Using authentication token from environment variable") 78 | 79 | # Filter out None values 80 | params = {k: v for k, v in params.items() if v is not None} 81 | 82 | if params: 83 | encoded_params = [] 84 | for key, value in params.items(): 85 | if isinstance(value, bool): 86 | value = str(value).lower() 87 | elif isinstance(value, list): 88 | if key == 'tags': 89 | # Tags should be comma-separated 90 | value = ','.join(str(v) for v in value) 91 | elif key == 'checklist-items': 92 | # Checklist items should be newline-separated 93 | value = '\n'.join(str(v) for v in value) 94 | else: 95 | value = ','.join(str(v) for v in value) 96 | 97 | # URL encode the value 98 | encoded_value = urllib.parse.quote(str(value), safe='') 99 | encoded_params.append(f"{key}={encoded_value}") 100 | 101 | url += "?" + "&".join(encoded_params) 102 | 103 | return url 104 | 105 | # URL scheme functions based on Things documentation 106 | 107 | def add_todo( 108 | title: str, 109 | notes: Optional[str] = None, 110 | when: Optional[str] = None, 111 | deadline: Optional[str] = None, 112 | tags: Optional[List[str]] = None, 113 | checklist_items: Optional[List[str]] = None, 114 | list_id: Optional[str] = None, 115 | list: Optional[str] = None, # Project/Area name 116 | heading: Optional[str] = None, 117 | completed: Optional[bool] = None 118 | ) -> bool: 119 | """Add a new todo using Things URL scheme.""" 120 | params = { 121 | 'title': title, 122 | 'notes': notes, 123 | 'when': when, 124 | 'deadline': deadline, 125 | 'tags': tags, 126 | 'checklist-items': checklist_items, 127 | 'list-id': list_id, 128 | 'list': list, # This is the project/area name 129 | 'heading': heading, 130 | 'completed': completed 131 | } 132 | 133 | url = construct_url('add', params) 134 | return execute_url(url) 135 | 136 | def add_project( 137 | title: str, 138 | notes: Optional[str] = None, 139 | when: Optional[str] = None, 140 | deadline: Optional[str] = None, 141 | tags: Optional[List[str]] = None, 142 | area_id: Optional[str] = None, 143 | area: Optional[str] = None, # Area name 144 | todos: Optional[List[str]] = None 145 | ) -> bool: 146 | """Add a new project using Things URL scheme.""" 147 | params = { 148 | 'title': title, 149 | 'notes': notes, 150 | 'when': when, 151 | 'deadline': deadline, 152 | 'tags': tags, 153 | 'area-id': area_id, 154 | 'area': area, 155 | 'to-dos': todos # List of todo titles 156 | } 157 | 158 | url = construct_url('add-project', params) 159 | return execute_url(url) 160 | 161 | def update_todo( 162 | id: str, 163 | title: Optional[str] = None, 164 | notes: Optional[str] = None, 165 | when: Optional[str] = None, 166 | deadline: Optional[str] = None, 167 | tags: Optional[List[str]] = None, 168 | add_tags: Optional[List[str]] = None, 169 | completed: Optional[bool] = None, 170 | canceled: Optional[bool] = None 171 | ) -> bool: 172 | """Update an existing todo.""" 173 | params = { 174 | 'id': id, 175 | 'title': title, 176 | 'notes': notes, 177 | 'when': when, 178 | 'deadline': deadline, 179 | 'tags': tags, 180 | 'add-tags': add_tags, 181 | 'completed': completed, 182 | 'canceled': canceled 183 | } 184 | 185 | url = construct_url('update', params) 186 | return execute_url(url) 187 | 188 | def update_project( 189 | id: str, 190 | title: Optional[str] = None, 191 | notes: Optional[str] = None, 192 | when: Optional[str] = None, 193 | deadline: Optional[str] = None, 194 | tags: Optional[List[str]] = None, 195 | completed: Optional[bool] = None, 196 | canceled: Optional[bool] = None 197 | ) -> bool: 198 | """Update an existing project.""" 199 | params = { 200 | 'id': id, 201 | 'title': title, 202 | 'notes': notes, 203 | 'when': when, 204 | 'deadline': deadline, 205 | 'tags': tags, 206 | 'completed': completed, 207 | 'canceled': canceled 208 | } 209 | 210 | url = construct_url('update-project', params) 211 | return execute_url(url) 212 | 213 | def show( 214 | id: str, 215 | query: Optional[str] = None, 216 | filter: Optional[List[str]] = None 217 | ) -> bool: 218 | """Show a specific item or list in Things.""" 219 | params = { 220 | 'id': id, 221 | 'query': query, 222 | 'filter': filter 223 | } 224 | 225 | url = construct_url('show', params) 226 | return execute_url(url) 227 | 228 | def search(query: str) -> bool: 229 | """Search for items in Things.""" 230 | return show(id='search', query=query) -------------------------------------------------------------------------------- /src/things_mcp/tag_handler.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | Tag handler for Things MCP. 4 | Ensures tags exist before applying them. 5 | """ 6 | import subprocess 7 | import logging 8 | from typing import List, Optional 9 | 10 | logger = logging.getLogger(__name__) 11 | 12 | def ensure_tags_exist(tags: List[str]) -> bool: 13 | """ 14 | Ensure all tags exist in Things before using them. 15 | Creates missing tags using AppleScript. 16 | 17 | Args: 18 | tags: List of tag names to ensure exist 19 | 20 | Returns: 21 | bool: True if all tags exist or were created successfully 22 | """ 23 | if not tags: 24 | return True 25 | 26 | try: 27 | # Build AppleScript to check and create tags 28 | script_lines = ['tell application "Things3"'] 29 | 30 | for tag in tags: 31 | # Escape quotes in tag name 32 | escaped_tag = tag.replace('"', '\\"') 33 | 34 | # Check if tag exists, create if not 35 | script_lines.extend([ 36 | f' set tagName to "{escaped_tag}"', 37 | ' set tagExists to false', 38 | ' repeat with t in tags', 39 | ' if name of t is tagName then', 40 | ' set tagExists to true', 41 | ' exit repeat', 42 | ' end if', 43 | ' end repeat', 44 | ' if not tagExists then', 45 | ' try', 46 | ' make new tag with properties {name:tagName}', 47 | f' log "Created tag: " & tagName', 48 | ' on error', 49 | f' log "Failed to create tag: " & tagName', 50 | ' end try', 51 | ' end if' 52 | ]) 53 | 54 | script_lines.append('end tell') 55 | script = '\n'.join(script_lines) 56 | 57 | # Execute the AppleScript 58 | result = subprocess.run( 59 | ['osascript', '-e', script], 60 | capture_output=True, 61 | text=True, 62 | timeout=10 63 | ) 64 | 65 | if result.returncode != 0: 66 | logger.error(f"Failed to ensure tags exist: {result.stderr}") 67 | return False 68 | 69 | logger.info(f"Ensured tags exist: {', '.join(tags)}") 70 | return True 71 | 72 | except subprocess.TimeoutExpired: 73 | logger.error("Timeout while ensuring tags exist") 74 | return False 75 | except Exception as e: 76 | logger.error(f"Error ensuring tags exist: {str(e)}") 77 | return False 78 | 79 | def get_existing_tags() -> List[str]: 80 | """ 81 | Get list of all existing tags in Things. 82 | 83 | Returns: 84 | List[str]: List of tag names 85 | """ 86 | try: 87 | script = '''tell application "Things3" 88 | set tagList to {} 89 | repeat with t in tags 90 | set end of tagList to name of t 91 | end repeat 92 | return tagList 93 | end tell''' 94 | 95 | result = subprocess.run( 96 | ['osascript', '-e', script], 97 | capture_output=True, 98 | text=True, 99 | timeout=5 100 | ) 101 | 102 | if result.returncode == 0 and result.stdout: 103 | # Parse the output (comma-separated list) 104 | tags = [tag.strip() for tag in result.stdout.strip().split(',')] 105 | return tags 106 | 107 | return [] 108 | 109 | except Exception as e: 110 | logger.error(f"Error getting existing tags: {str(e)}") 111 | return [] -------------------------------------------------------------------------------- /src/things_mcp/things_server.py: -------------------------------------------------------------------------------- 1 | from typing import Any, List, Optional, Dict 2 | import logging 3 | import asyncio 4 | import sys 5 | import re 6 | import traceback 7 | from mcp.server.models import InitializationOptions 8 | import mcp.types as types 9 | from mcp.server import NotificationOptions, Server 10 | import mcp.server.stdio 11 | 12 | # Import our direct MCP tool definitions for Windsurf compatibility 13 | from mcp_tools import get_mcp_tools_list 14 | from handlers import handle_tool_call 15 | from utils import validate_tool_registration, app_state 16 | import url_scheme 17 | 18 | # Configure logging 19 | logging.basicConfig(level=logging.DEBUG) 20 | logger = logging.getLogger(__name__) 21 | 22 | server = Server("things") 23 | 24 | @server.list_tools() 25 | async def handle_list_tools() -> list[types.Tool]: 26 | """List available tools for Things integration with Windsurf compatibility.""" 27 | return get_mcp_tools_list() 28 | 29 | @server.call_tool() 30 | async def handle_call_tool( 31 | name: str, arguments: dict | None 32 | ) -> list[types.TextContent | types.ImageContent | types.EmbeddedResource]: 33 | """Handle tool execution requests with Windsurf compatibility.""" 34 | try: 35 | # Handle both prefixed and non-prefixed tool names consistently 36 | # If name has mcp2_ prefix, remove it for handler compatibility 37 | # If name doesn't have prefix, use it directly 38 | 39 | original_name = name 40 | base_name = name 41 | 42 | # Check if the name has the 'mcp2_' prefix and remove it if present 43 | if name.startswith("mcp2_"): 44 | base_name = name[5:] # Remove the 'mcp2_' prefix 45 | logger.info(f"Received prefixed tool call: {name} -> mapping to {base_name}") 46 | else: 47 | # No prefix, check if the name is one of our supported tools 48 | # This allows both prefixed and direct calls to work 49 | logger.info(f"Received non-prefixed tool call: {name}") 50 | 51 | # Log the incoming arguments for debugging 52 | argument_summary = str(arguments)[:100] + "..." if arguments and len(str(arguments)) > 100 else str(arguments) 53 | logger.info(f"MCP tool call received: {original_name} (handling as: {base_name}) with arguments: {argument_summary}") 54 | 55 | # Call the appropriate handler with robust error handling 56 | try: 57 | return await handle_tool_call(base_name, arguments) 58 | except Exception as e: 59 | error_message = f"Error executing tool {name}: {str(e)}" 60 | logger.error(error_message) 61 | logger.error(traceback.format_exc()) 62 | return [types.TextContent(type="text", text=f"⚠️ {error_message}")] 63 | except Exception as outer_e: 64 | # Catch-all to prevent server crashes 65 | logger.error(f"Critical error in tool call handler: {str(outer_e)}") 66 | logger.error(traceback.format_exc()) 67 | return [types.TextContent(type="text", text=f"⚠️ Critical error: {str(outer_e)}")] 68 | 69 | async def main(): 70 | # Get our MCP tools with proper naming for Windsurf 71 | mcp_tools = get_mcp_tools_list() 72 | 73 | # Log successful registration 74 | logger.info(f"Registered {len(mcp_tools)} MCP-compatible tools for Things") 75 | 76 | # Check if Things app is available 77 | if not app_state.update_app_state(): 78 | logger.warning("Things app is not running at startup. MCP will attempt to launch it when needed.") 79 | try: 80 | # Try to launch Things 81 | if url_scheme.launch_things(): 82 | logger.info("Successfully launched Things app") 83 | else: 84 | logger.error("Unable to launch Things app. Some operations may fail.") 85 | except Exception as e: 86 | logger.error(f"Error launching Things app: {str(e)}") 87 | else: 88 | logger.info("Things app is running and ready for operations") 89 | 90 | # Run the server using stdin/stdout streams 91 | async with mcp.server.stdio.stdio_server() as (read_stream, write_stream): 92 | await server.run( 93 | read_stream, 94 | write_stream, 95 | InitializationOptions( 96 | server_name="things", 97 | server_version="0.1.1", # Updated version with reliability enhancements 98 | capabilities=server.get_capabilities( 99 | notification_options=NotificationOptions(), 100 | experimental_capabilities={}, 101 | ), 102 | ), 103 | ) 104 | 105 | if __name__ == "__main__": 106 | asyncio.run(main()) -------------------------------------------------------------------------------- /src/things_mcp/url_scheme.py: -------------------------------------------------------------------------------- 1 | import urllib.parse 2 | import webbrowser 3 | import things 4 | import subprocess 5 | import platform 6 | import random 7 | import time 8 | import logging 9 | import json 10 | from typing import Optional, Dict, Any, Union, Callable 11 | from .utils import app_state, circuit_breaker, dead_letter_queue, rate_limiter, is_things_running 12 | 13 | logger = logging.getLogger(__name__) 14 | 15 | def launch_things() -> bool: 16 | """Launch Things app if not already running. 17 | 18 | Returns: 19 | bool: True if successful, False otherwise 20 | """ 21 | try: 22 | if is_things_running(): 23 | return True 24 | 25 | result = subprocess.run( 26 | ['open', '-a', 'Things3'], 27 | capture_output=True, 28 | text=True, 29 | check=False 30 | ) 31 | 32 | # Give Things time to launch 33 | time.sleep(2) 34 | 35 | return is_things_running() 36 | except Exception as e: 37 | logger.error(f"Error launching Things: {str(e)}") 38 | return False 39 | 40 | def execute_url(url: str) -> bool: 41 | """Execute a Things URL by opening it in the default browser. 42 | Returns True if successful, False otherwise. 43 | """ 44 | # Ensure any + signs in the URL are replaced with %20 45 | url = url.replace("+", "%20") 46 | 47 | # Log the URL for debugging 48 | logger.debug(f"Executing URL: {url}") 49 | 50 | # Apply rate limiting 51 | rate_limiter.wait_if_needed() 52 | 53 | # Check if circuit breaker allows the operation 54 | if not circuit_breaker.allow_operation(): 55 | logger.warning("Circuit breaker is open, blocking operation") 56 | return False 57 | 58 | try: 59 | # Check if Things is running, attempt to launch if not 60 | if not is_things_running(): 61 | logger.info("Things is not running, attempting to launch") 62 | if not launch_things(): 63 | logger.error("Failed to launch Things") 64 | circuit_breaker.record_failure() 65 | return False 66 | 67 | # Execute the URL 68 | result = webbrowser.open(url) 69 | 70 | if not result: 71 | circuit_breaker.record_failure() 72 | logger.error(f"Failed to open URL: {url}") 73 | return False 74 | 75 | # Add a small delay to allow Things time to process the command 76 | # Add jitter to prevent thundering herd problem 77 | delay = 0.5 + random.uniform(0, 0.2) # 0.5-0.7 seconds 78 | time.sleep(delay) 79 | 80 | circuit_breaker.record_success() 81 | return True 82 | except Exception as e: 83 | logger.error(f"Failed to execute URL: {url}, Error: {str(e)}") 84 | circuit_breaker.record_failure() 85 | return False 86 | 87 | 88 | def execute_xcallback_url(action: str, params: Dict[str, Any]) -> bool: 89 | """Execute a Things X-Callback-URL. 90 | 91 | Args: 92 | action: The X-Callback action to perform 93 | params: Parameters for the action 94 | 95 | Returns: 96 | bool: True if successful, False otherwise 97 | """ 98 | # The correct format for Things URLs (no 'x-callback-url/' prefix) 99 | base_url = "things:///" 100 | 101 | # Add callback parameters, but only if we need them 102 | # For now, avoid using callbacks since we don't have a handler for them 103 | callback_params = params.copy() 104 | 105 | # Don't add callback URLs - this avoids the "no application set to open URL" error 106 | # If we need callbacks later, we'd need to register a URL handler for our app 107 | 108 | # Construct URL - action is part of the path (not a separate query parameter) 109 | url = f"{base_url}{action}?{urllib.parse.urlencode(callback_params)}" 110 | 111 | # Log the URL for debugging 112 | logger.debug(f"Executing URL: {url}") 113 | 114 | return execute_url(url) 115 | 116 | def construct_url(command: str, params: Dict[str, Any]) -> str: 117 | """Construct a Things URL from command and parameters.""" 118 | # Pre-process all string parameters to replace any + signs with spaces 119 | cleaned_params = {} 120 | for key, value in params.items(): 121 | if isinstance(value, str): 122 | # Replace any + signs with spaces in the original input 123 | cleaned_params[key] = value.replace("+", " ") 124 | else: 125 | cleaned_params[key] = value 126 | 127 | # Use the cleaned params from now on 128 | params = cleaned_params 129 | 130 | # Start with base URL 131 | url = f"things:///{command}" 132 | 133 | # Get authentication token if needed - applies to all commands to ensure reliability 134 | try: 135 | # Import here to avoid circular imports 136 | from . import config 137 | 138 | # Get token from config system 139 | token = config.get_things_auth_token() 140 | 141 | if token: 142 | # Add token to all params for consistent behavior 143 | params['auth-token'] = token 144 | logger.debug(f"Auth token from config used for {command} operation") 145 | else: 146 | logger.warning(f"No Things auth token found in config. URL may not work without a token.") 147 | # Note: We continue without a token, which may cause the operation to fail 148 | except Exception as e: 149 | logger.error(f"Error getting auth token: {str(e)}") 150 | # Continue without token - the operation may fail 151 | 152 | # Disable JSON API for now as it's causing formatting issues 153 | # JSON API is currently experimental and unreliable 154 | # We'll use the standard URL scheme instead which is more reliable 155 | use_json_api = False 156 | 157 | if False and command in ['add'] and use_json_api: 158 | # This code is disabled but kept for reference 159 | logger.info("JSON API is currently disabled due to formatting issues") 160 | pass 161 | 162 | # Standard URL scheme encoding 163 | if params: 164 | encoded_params = [] 165 | for key, value in params.items(): 166 | if value is None: 167 | continue 168 | # Handle boolean values 169 | if isinstance(value, bool): 170 | value = str(value).lower() 171 | # Handle lists (for tags, checklist items etc) 172 | elif isinstance(value, list) and key == 'tags': 173 | # Important: Tags are sensitive to formatting in Things URL scheme 174 | # Based on testing, using a simple comma-separated list without spaces works best 175 | encoded_tags = [] 176 | for tag in value: 177 | # Ensure tag is properly encoded as string 178 | tag_str = str(tag).strip() 179 | if tag_str: # Only add non-empty tags 180 | encoded_tags.append(tag_str) 181 | 182 | # Only include non-empty tag lists 183 | if encoded_tags: 184 | # Join with commas - Things expects comma-separated tags without spaces between commas 185 | # Use a simple comma with no spacing for maximum compatibility 186 | value = ','.join(encoded_tags) 187 | else: 188 | # If no valid tags, don't include this parameter 189 | continue 190 | # Handle other lists 191 | elif isinstance(value, list): 192 | value = ','.join(str(v) for v in value) 193 | 194 | # Ensure proper encoding of the value - use quote_plus to handle spaces correctly 195 | # Then replace + with %20 to ensure Things handles spaces correctly 196 | encoded_value = urllib.parse.quote(str(value), safe='') 197 | # Replace + with %20 for better compatibility with Things 198 | encoded_value = encoded_value.replace('+', '%20') 199 | encoded_params.append(f"{key}={encoded_value}") 200 | 201 | url += "?" + "&".join(encoded_params) 202 | 203 | return url 204 | 205 | 206 | def should_use_json_api() -> bool: 207 | """Determine if the JSON API should be used based on Things version.""" 208 | from .utils import detect_things_version 209 | 210 | version = detect_things_version() 211 | if not version: 212 | # Default to using JSON API if version can't be determined 213 | return True 214 | 215 | try: 216 | # Parse version string (e.g., '3.15.4') 217 | major, minor, _ = map(int, version.split('.')) 218 | 219 | # JSON API is available in Things 3.4+ 220 | return major > 3 or (major == 3 and minor >= 4) 221 | except Exception: 222 | # Default to standard URL scheme if version parsing fails 223 | return False 224 | 225 | def add_todo(title: str, notes: Optional[str] = None, when: Optional[str] = None, 226 | deadline: Optional[str] = None, tags: Optional[list[str]] = None, 227 | checklist_items: Optional[list[str]] = None, list_id: Optional[str] = None, 228 | list_title: Optional[str] = None, heading: Optional[str] = None, 229 | completed: Optional[bool] = None) -> str: 230 | """Construct URL to add a new todo.""" 231 | params = { 232 | 'title': title, 233 | 'notes': notes, 234 | 'when': when, 235 | 'deadline': deadline, 236 | 'checklist-items': '\n'.join(checklist_items) if checklist_items else None, 237 | 'list-id': list_id, 238 | 'list': list_title, 239 | 'heading': heading, 240 | 'completed': completed 241 | } 242 | 243 | # Handle tags separately since they need to be comma-separated 244 | if tags: 245 | params['tags'] = ','.join(tags) 246 | return construct_url('add', {k: v for k, v in params.items() if v is not None}) 247 | 248 | def add_project(title: str, notes: Optional[str] = None, when: Optional[str] = None, 249 | deadline: Optional[str] = None, tags: Optional[list[str]] = None, 250 | area_id: Optional[str] = None, area_title: Optional[str] = None, 251 | todos: Optional[list[str]] = None) -> str: 252 | """Construct URL to add a new project.""" 253 | params = { 254 | 'title': title, 255 | 'notes': notes, 256 | 'when': when, 257 | 'deadline': deadline, 258 | 'area-id': area_id, 259 | 'area': area_title, 260 | # Change todos to be newline separated 261 | 'to-dos': '\n'.join(todos) if todos else None 262 | } 263 | 264 | # Handle tags separately since they need to be comma-separated 265 | if tags: 266 | params['tags'] = ','.join(tags) 267 | 268 | return construct_url('add-project', {k: v for k, v in params.items() if v is not None}) 269 | 270 | def update_todo(id: str, title: Optional[str] = None, notes: Optional[str] = None, 271 | when: Optional[str] = None, deadline: Optional[str] = None, 272 | tags: Optional[Union[list[str], str]] = None, add_tags: Optional[Union[list[str], str]] = None, 273 | checklist_items: Optional[list[str]] = None, 274 | completed: Optional[bool] = None, canceled: Optional[bool] = None) -> str: 275 | """Construct URL to update an existing todo.""" 276 | params = { 277 | 'id': id, 278 | 'title': title, 279 | 'notes': notes, 280 | 'when': when, 281 | 'deadline': deadline, 282 | 'tags': tags, 283 | 'add-tags': add_tags, # Support for adding tags without replacing existing ones 284 | 'checklist-items': '\n'.join(checklist_items) if checklist_items else None, 285 | 'completed': completed, 286 | 'canceled': canceled 287 | } 288 | return construct_url('update', {k: v for k, v in params.items() if v is not None}) 289 | 290 | def update_project(id: str, title: Optional[str] = None, notes: Optional[str] = None, 291 | when: Optional[str] = None, deadline: Optional[str] = None, 292 | tags: Optional[list[str]] = None, completed: Optional[bool] = None, 293 | canceled: Optional[bool] = None) -> str: 294 | """Construct URL to update an existing project.""" 295 | params = { 296 | 'id': id, 297 | 'title': title, 298 | 'notes': notes, 299 | 'when': when, 300 | 'deadline': deadline, 301 | 'tags': tags, 302 | 'completed': completed, 303 | 'canceled': canceled 304 | } 305 | return construct_url('update-project', {k: v for k, v in params.items() if v is not None}) 306 | 307 | def show(id: str, query: Optional[str] = None, filter_tags: Optional[list[str]] = None) -> str: 308 | """Construct URL to show a specific item or list.""" 309 | params = { 310 | 'id': id, 311 | 'query': query, 312 | 'filter': filter_tags 313 | } 314 | return construct_url('show', {k: v for k, v in params.items() if v is not None}) 315 | 316 | def search(query: str) -> str: 317 | """Construct URL to perform a search.""" 318 | return construct_url('search', {'query': query}) -------------------------------------------------------------------------------- /src/things_mcp/utils.py: -------------------------------------------------------------------------------- 1 | """ 2 | Utility classes and functions for enhancing Things MCP reliability. 3 | """ 4 | import json 5 | import time 6 | import logging 7 | import os 8 | import platform 9 | import subprocess 10 | import urllib.parse 11 | import mcp.types as types 12 | from typing import Dict, Any, Optional, Callable, List, Union 13 | 14 | logger = logging.getLogger(__name__) 15 | 16 | 17 | def is_things_running() -> bool: 18 | """Check if Things app is running. 19 | 20 | Returns: 21 | bool: True if Things is running, False otherwise 22 | """ 23 | try: 24 | if platform.system() != 'Darwin': 25 | logger.warning("Things availability check only works on macOS") 26 | return True 27 | 28 | result = subprocess.run( 29 | ['osascript', '-e', 'tell application "System Events" to (name of processes) contains "Things3"'], 30 | capture_output=True, 31 | text=True, 32 | check=False 33 | ) 34 | is_running = result.stdout.strip().lower() == 'true' 35 | 36 | # If Things is running, also check if it's responsive 37 | if is_running: 38 | # Simple ping to see if Things responds 39 | ping_result = subprocess.run( 40 | ['osascript', '-e', 'tell application "Things3" to return name'], 41 | capture_output=True, 42 | text=True, 43 | check=False, 44 | timeout=2 # 2 second timeout 45 | ) 46 | is_responsive = ping_result.returncode == 0 47 | 48 | if not is_responsive: 49 | logger.warning("Things is running but not responsive") 50 | return False 51 | 52 | return is_running 53 | except subprocess.TimeoutExpired: 54 | logger.warning("Things app check timed out") 55 | return False 56 | except Exception as e: 57 | logger.error(f"Error checking if Things is running: {str(e)}") 58 | return False 59 | 60 | class ThingsAppState: 61 | """Track and manage Things app state""" 62 | 63 | def __init__(self): 64 | self.is_available = False 65 | self.last_check_time = 0 66 | self.check_interval = 5 # seconds 67 | self.update_app_state() 68 | 69 | def update_app_state(self): 70 | """Update app availability state""" 71 | current_time = time.time() 72 | if current_time - self.last_check_time > self.check_interval: 73 | self.is_available = is_things_running() 74 | self.last_check_time = current_time 75 | return self.is_available 76 | 77 | def wait_for_app_availability(self, timeout=10): 78 | """Wait for app to become available within timeout""" 79 | start_time = time.time() 80 | while time.time() - start_time < timeout: 81 | if self.update_app_state(): 82 | return True 83 | time.sleep(0.5) 84 | return False 85 | 86 | 87 | def validate_tool_registration(tools: list[types.Tool]) -> bool: 88 | """ 89 | Validate that all required Things MCP tools are properly registered. 90 | 91 | Args: 92 | tools: List of registered tools 93 | 94 | Returns: 95 | bool: True if all required tools are registered, False otherwise 96 | """ 97 | required_tool_names = [ 98 | "get-inbox", "get-today", "get-upcoming", "get-anytime", 99 | "get-someday", "get-logbook", "get-trash", "get-todos", 100 | "get-projects", "get-areas", "get-tags", "get-tagged-items", 101 | "search-todos", "search-advanced", "get-recent", "add-todo", 102 | "search-items", "add-project", "update-todo", "update-project", "show-item" 103 | ] 104 | 105 | registered_tool_names = [tool.name for tool in tools] 106 | 107 | # Check if all required tools are registered 108 | missing_tools = [name for name in required_tool_names if name not in registered_tool_names] 109 | 110 | if missing_tools: 111 | logger.error(f"Missing required tool registrations: {missing_tools}") 112 | return False 113 | 114 | # Check if all registered tools have proper descriptions and parameters 115 | for tool in tools: 116 | if not tool.description or len(tool.description) < 10: 117 | logger.warning(f"Tool '{tool.name}' has an insufficient description") 118 | 119 | # Basic parameter validation could be added here 120 | # This would depend on your tool schema requirements 121 | 122 | return True 123 | 124 | 125 | class CircuitBreaker: 126 | """Circuit breaker to prevent repeated failed attempts""" 127 | 128 | # Circuit states 129 | CLOSED = "closed" # Normal operation 130 | OPEN = "open" # Not allowing operations 131 | HALF_OPEN = "half-open" # Testing if system has recovered 132 | 133 | def __init__(self, failure_threshold=5, recovery_timeout=60): 134 | self.state = self.CLOSED 135 | self.failure_count = 0 136 | self.failure_threshold = failure_threshold 137 | self.recovery_timeout = recovery_timeout 138 | self.last_failure_time = 0 139 | 140 | def record_failure(self): 141 | """Record a failure and potentially open the circuit""" 142 | self.failure_count += 1 143 | self.last_failure_time = time.time() 144 | 145 | if self.failure_count >= self.failure_threshold: 146 | self.state = self.OPEN 147 | logger.warning(f"Circuit breaker opened after {self.failure_count} failures") 148 | 149 | def record_success(self): 150 | """Record a success and reset the circuit if in half-open state""" 151 | if self.state == self.HALF_OPEN: 152 | self.state = self.CLOSED 153 | self.failure_count = 0 154 | logger.info("Circuit breaker closed after successful operation") 155 | elif self.state == self.CLOSED: 156 | self.failure_count = 0 157 | 158 | def allow_operation(self): 159 | """Check if operation should be allowed""" 160 | if self.state == self.CLOSED: 161 | return True 162 | 163 | if self.state == self.OPEN: 164 | # Check if recovery timeout has elapsed 165 | if time.time() - self.last_failure_time > self.recovery_timeout: 166 | self.state = self.HALF_OPEN 167 | logger.info("Circuit breaker half-open, testing system recovery") 168 | return True 169 | return False 170 | 171 | # Half-open state allows a single test operation 172 | return True 173 | 174 | 175 | class DeadLetterQueue: 176 | """Store persistently failed operations for manual review""" 177 | 178 | def __init__(self, dlq_file="things_dlq.json"): 179 | self.dlq_file = dlq_file 180 | self.queue = self._load_queue() 181 | 182 | def _load_queue(self): 183 | """Load persisted queue""" 184 | try: 185 | if os.path.exists(self.dlq_file): 186 | with open(self.dlq_file, 'r') as f: 187 | return json.load(f) 188 | return [] 189 | except Exception as e: 190 | logger.error(f"Error loading DLQ: {str(e)}") 191 | return [] 192 | 193 | def _save_queue(self): 194 | """Persist queue to disk""" 195 | try: 196 | with open(self.dlq_file, 'w') as f: 197 | json.dump(self.queue, f) 198 | except Exception as e: 199 | logger.error(f"Error saving DLQ: {str(e)}") 200 | 201 | def add_failed_operation(self, operation, params, error, attempts=1): 202 | """Add failed operation to dead letter queue""" 203 | entry = { 204 | "operation": operation, 205 | "params": params, 206 | "error": str(error), 207 | "attempts": attempts, 208 | "timestamp": time.time(), 209 | "added_at": time.strftime("%Y-%m-%d %H:%M:%S") 210 | } 211 | 212 | self.queue.append(entry) 213 | self._save_queue() 214 | logger.warning(f"Added to DLQ: {operation} with error: {str(error)}") 215 | 216 | def retry_all(self): 217 | """Attempt to retry all operations in the DLQ""" 218 | if not self.queue: 219 | return {"success": True, "retried": 0, "failed": 0} 220 | 221 | from .url_scheme import construct_url, execute_url 222 | 223 | success_count = 0 224 | failure_count = 0 225 | remaining_queue = [] 226 | 227 | for entry in self.queue: 228 | try: 229 | url = construct_url(entry["operation"], entry["params"]) 230 | result = retry_operation(lambda: execute_url(url)) 231 | 232 | if result: 233 | success_count += 1 234 | else: 235 | entry["attempts"] += 1 236 | remaining_queue.append(entry) 237 | failure_count += 1 238 | except Exception: 239 | entry["attempts"] += 1 240 | remaining_queue.append(entry) 241 | failure_count += 1 242 | 243 | self.queue = remaining_queue 244 | self._save_queue() 245 | 246 | return { 247 | "success": failure_count == 0, 248 | "retried": success_count + failure_count, 249 | "failed": failure_count 250 | } 251 | 252 | 253 | class RateLimiter: 254 | """Intelligent rate limiter for Things operations""" 255 | 256 | def __init__(self, operations_per_minute=30): 257 | self.operations_per_minute = operations_per_minute 258 | self.operation_interval = 60 / operations_per_minute 259 | self.last_operation_time = 0 260 | 261 | def wait_if_needed(self): 262 | """Wait if necessary to maintain rate limit""" 263 | current_time = time.time() 264 | time_since_last = current_time - self.last_operation_time 265 | 266 | if time_since_last < self.operation_interval: 267 | # Need to wait 268 | wait_time = self.operation_interval - time_since_last 269 | time.sleep(wait_time) 270 | 271 | self.last_operation_time = time.time() 272 | 273 | def __call__(self, func): 274 | """Decorator to rate limit a function""" 275 | def wrapper(*args, **kwargs): 276 | self.wait_if_needed() 277 | return func(*args, **kwargs) 278 | return wrapper 279 | 280 | 281 | def get_auth_token() -> Optional[str]: 282 | """Get the Things authentication token from various possible sources. 283 | 284 | The function tries to get the token from: 285 | 1. Environment variable THINGS_AUTH_TOKEN 286 | 2. Local config file 287 | 3. Hardcoded fallback value 288 | 289 | Returns: 290 | str: Authentication token if found, None otherwise 291 | """ 292 | # Try environment variable first 293 | token = os.environ.get('THINGS_AUTH_TOKEN') 294 | if token: 295 | logger.info("Using Things authentication token from environment variable") 296 | return token 297 | 298 | # Try local config file 299 | try: 300 | config_path = os.path.expanduser("~/.things_config.json") 301 | if os.path.exists(config_path): 302 | with open(config_path, 'r') as f: 303 | config = json.load(f) 304 | if 'auth_token' in config and config['auth_token']: 305 | logger.info("Using Things authentication token from config file") 306 | return config['auth_token'] 307 | except Exception as e: 308 | logger.warning(f"Failed to read auth token from config file: {str(e)}") 309 | 310 | # No token found from dynamic sources 311 | logger.warning("No Things authentication token found in environment or config") 312 | return None 313 | 314 | 315 | def detect_things_version(): 316 | """Detect installed Things version""" 317 | try: 318 | # Use AppleScript to get Things version 319 | script = 'tell application "Things3" to return version' 320 | result = subprocess.run( 321 | ['osascript', '-e', script], 322 | capture_output=True, 323 | text=True, 324 | check=False 325 | ) 326 | 327 | if result.returncode == 0 and result.stdout.strip(): 328 | return result.stdout.strip() 329 | except Exception as e: 330 | logger.error(f"Error detecting Things version: {str(e)}") 331 | 332 | return None 333 | 334 | 335 | def validate_tool_registration(tool_list): 336 | """Validate that all tools are properly registered""" 337 | required_tools = [ 338 | "get-inbox", "get-today", "get-upcoming", "get-anytime", 339 | "get-someday", "get-logbook", "get-trash", "get-todos", 340 | "get-projects", "get-areas", "get-tags", "get-tagged-items", 341 | "search-todos", "search-advanced", "get-recent", "add-todo", 342 | "add-project", "update-todo", "update-project", "show-item" 343 | ] 344 | 345 | tool_names = [t.name for t in tool_list] 346 | missing_tools = [tool for tool in required_tools if tool not in tool_names] 347 | 348 | if missing_tools: 349 | logger.error(f"Missing tool registrations: {missing_tools}") 350 | return False 351 | 352 | logger.info(f"All {len(tool_list)} tools are properly registered") 353 | return True 354 | 355 | 356 | # Create global instances 357 | app_state = ThingsAppState() 358 | circuit_breaker = CircuitBreaker() 359 | dead_letter_queue = DeadLetterQueue() 360 | rate_limiter = RateLimiter() 361 | -------------------------------------------------------------------------------- /things_fast_server.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | Main entry point for running the Things MCP server. 4 | This version uses the modern FastMCP pattern for better maintainability. 5 | """ 6 | import logging 7 | import sys 8 | from src.things_mcp.fast_server import run_things_mcp_server 9 | 10 | # Configure logging 11 | logging.basicConfig( 12 | level=logging.INFO, 13 | format="%(asctime)s - %(name)s - %(levelname)s - %(message)s" 14 | ) 15 | logger = logging.getLogger(__name__) 16 | 17 | if __name__ == "__main__": 18 | logger.info("Starting Things FastMCP Server") 19 | try: 20 | run_things_mcp_server() 21 | except KeyboardInterrupt: 22 | logger.info("Server stopped by user") 23 | sys.exit(0) 24 | except Exception as e: 25 | logger.error(f"Error running server: {str(e)}") 26 | sys.exit(1) 27 | -------------------------------------------------------------------------------- /things_server.py: -------------------------------------------------------------------------------- 1 | from typing import Any, List, Optional, Dict 2 | import logging 3 | import asyncio 4 | import sys 5 | import re 6 | import traceback 7 | from mcp.server.models import InitializationOptions 8 | import mcp.types as types 9 | from mcp.server import NotificationOptions, Server 10 | import mcp.server.stdio 11 | 12 | # Import our direct MCP tool definitions for Windsurf compatibility 13 | from mcp_tools import get_mcp_tools_list 14 | from handlers import handle_tool_call 15 | from utils import validate_tool_registration, app_state 16 | import url_scheme 17 | 18 | # Configure logging 19 | logging.basicConfig(level=logging.DEBUG) 20 | logger = logging.getLogger(__name__) 21 | 22 | server = Server("things") 23 | 24 | @server.list_tools() 25 | async def handle_list_tools() -> list[types.Tool]: 26 | """List available tools for Things integration with Windsurf compatibility.""" 27 | return get_mcp_tools_list() 28 | 29 | @server.call_tool() 30 | async def handle_call_tool( 31 | name: str, arguments: dict | None 32 | ) -> list[types.TextContent | types.ImageContent | types.EmbeddedResource]: 33 | """Handle tool execution requests with Windsurf compatibility.""" 34 | try: 35 | # Handle both prefixed and non-prefixed tool names consistently 36 | # If name has mcp2_ prefix, remove it for handler compatibility 37 | # If name doesn't have prefix, use it directly 38 | 39 | original_name = name 40 | base_name = name 41 | 42 | # Check if the name has the 'mcp2_' prefix and remove it if present 43 | if name.startswith("mcp2_"): 44 | base_name = name[5:] # Remove the 'mcp2_' prefix 45 | logger.info(f"Received prefixed tool call: {name} -> mapping to {base_name}") 46 | else: 47 | # No prefix, check if the name is one of our supported tools 48 | # This allows both prefixed and direct calls to work 49 | logger.info(f"Received non-prefixed tool call: {name}") 50 | 51 | # Log the incoming arguments for debugging 52 | argument_summary = str(arguments)[:100] + "..." if arguments and len(str(arguments)) > 100 else str(arguments) 53 | logger.info(f"MCP tool call received: {original_name} (handling as: {base_name}) with arguments: {argument_summary}") 54 | 55 | # Call the appropriate handler with robust error handling 56 | try: 57 | return await handle_tool_call(base_name, arguments) 58 | except Exception as e: 59 | error_message = f"Error executing tool {name}: {str(e)}" 60 | logger.error(error_message) 61 | logger.error(traceback.format_exc()) 62 | return [types.TextContent(type="text", text=f"⚠️ {error_message}")] 63 | except Exception as outer_e: 64 | # Catch-all to prevent server crashes 65 | logger.error(f"Critical error in tool call handler: {str(outer_e)}") 66 | logger.error(traceback.format_exc()) 67 | return [types.TextContent(type="text", text=f"⚠️ Critical error: {str(outer_e)}")] 68 | 69 | async def main(): 70 | # Get our MCP tools with proper naming for Windsurf 71 | mcp_tools = get_mcp_tools_list() 72 | 73 | # Log successful registration 74 | logger.info(f"Registered {len(mcp_tools)} MCP-compatible tools for Things") 75 | 76 | # Check if Things app is available 77 | if not app_state.update_app_state(): 78 | logger.warning("Things app is not running at startup. MCP will attempt to launch it when needed.") 79 | try: 80 | # Try to launch Things 81 | if url_scheme.launch_things(): 82 | logger.info("Successfully launched Things app") 83 | else: 84 | logger.error("Unable to launch Things app. Some operations may fail.") 85 | except Exception as e: 86 | logger.error(f"Error launching Things app: {str(e)}") 87 | else: 88 | logger.info("Things app is running and ready for operations") 89 | 90 | # Run the server using stdin/stdout streams 91 | async with mcp.server.stdio.stdio_server() as (read_stream, write_stream): 92 | await server.run( 93 | read_stream, 94 | write_stream, 95 | InitializationOptions( 96 | server_name="things", 97 | server_version="0.1.1", # Updated version with reliability enhancements 98 | capabilities=server.get_capabilities( 99 | notification_options=NotificationOptions(), 100 | experimental_capabilities={}, 101 | ), 102 | ), 103 | ) 104 | 105 | if __name__ == "__main__": 106 | asyncio.run(main()) --------------------------------------------------------------------------------