Things 3 MCP Server - A Model Context Protocol server that enables AI assistants to interact with Things 3 via AppleScript on macOS.
- 🏷️ Tag Management - Fixed tag concatenation in all tag operations (add_tags, remove_tags, bulk_update_todos)
- ⚡ Bulk Operations - Fixed multi-field updates; tags now work correctly in batch operations
- 📅 Date Scheduling - Reliable scheduling with
today,tomorrow,someday, or specific dates (YYYY-MM-DD) - ✅ Validation - Parameter validation prevents common errors and edge cases
- 📊 Context Optimization - Response modes provide 5-12x better performance than documented
- Framework: FastMCP 2.0 (Python 3.8+)
- Integration: AppleScript via subprocess calls
- Testing: pytest with mocked AppleScript operations
- Platform: macOS 12.0+ with Things 3 installed
- Keep it simple and maintainable - no over-engineering
- Follow existing patterns in the codebase
- Add type hints to all new functions
- Document with clear docstrings (Google style)
# Run tests before committing
pytest # Run all tests
pytest tests/unit/ # Unit tests only
pytest tests/integration/ # Integration tests
pytest --cov=src/things_mcp # With coveragesrc/things_mcp/ # Source code
tests/ # Test files
docs/ # Documentation
When working with AppleScript:
- Escape quotes properly - Use
_escape_applescript_string() - Handle errors gracefully - AppleScript can fail silently
- Test with real Things 3 - Mock tests don't catch all issues
- Check permissions - Automation access must be granted
Example pattern:
script = f'''
tell application "Things3"
set newTodo to make new to do with properties {{name:"{escaped_title}"}}
return id of newTodo
end tell
'''
result = self.applescript_manager.execute_script(script)- Tag must exist first: AI cannot create tags automatically - use
get_tags()to check available tags - Large data timeouts: Use response modes (summary, minimal) and pagination
- Date formats: Always use ISO 8601 format (YYYY-MM-DD) for best reliability
- Permission errors: System Settings → Privacy & Security → Automation → Enable Things 3 access
- Implemented: 25+ operations (40% of AppleScript API)
- Tested: All features verified with comprehensive integration tests
- Roadmap: See
docs/ROADMAP.mdfor future features - Priority: Focus on daily workflow operations
Issue: remove_tags() was treating tag strings as character arrays, removing individual characters instead of complete tag names.
# ❌ BEFORE (Broken)
remove_tags(todo_id="123", tags="test,Work")
# Would try to remove: ['t','e','s','t',',','W','o','r','k']
# ✅ AFTER (Fixed)
remove_tags(todo_id="123", tags="test,Work")
# Correctly removes: ['test', 'Work']Correct Usage:
# Single tag
remove_tags(todo_id="abc123", tags="urgent")
# Multiple tags (comma-separated, no spaces)
remove_tags(todo_id="abc123", tags="test,High,Work")
# Tag names are case-sensitive
remove_tags(todo_id="abc123", tags="Work") # Removes "Work"
remove_tags(todo_id="abc123", tags="work") # Removes "work" (different tag)Notes:
- Tag names are case-sensitive in Things 3
- Non-existent tags are silently filtered (no error)
- Use comma separation without spaces:
"tag1,tag2,tag3"
Issue: bulk_update_todos() was only applying the last field in multi-field updates due to script execution order.
# ❌ BEFORE (Broken - only deadline applied)
bulk_update_todos(
todo_ids="1,2,3",
tags="urgent,work",
deadline="2025-12-31"
)
# ✅ AFTER (Fixed - all fields applied)
bulk_update_todos(
todo_ids="1,2,3",
tags="urgent,work",
deadline="2025-12-31"
)Correct Usage:
# Single field updates (always worked)
bulk_update_todos(todo_ids="1,2,3", completed="true")
bulk_update_todos(todo_ids="1,2,3", tags="urgent")
bulk_update_todos(todo_ids="1,2,3", when="today")
# Multi-field updates (now fixed)
bulk_update_todos(
todo_ids="abc,def,ghi",
tags="urgent,work",
when="today",
notes="Updated via bulk operation"
)
bulk_update_todos(
todo_ids="1,2,3",
tags="test,review",
deadline="2025-12-31",
notes="Q4 deliverables"
)
# Complete status change with metadata
bulk_update_todos(
todo_ids="task1,task2",
completed="true",
notes="Completed in sprint review"
)Supported Fields:
title- Update todo titlenotes- Update todo noteswhen- Update scheduling (e.g.,"today","tomorrow","2025-12-31")deadline- Update deadline datetags- Replace tags (comma-separated)completed- Mark as complete ("true") or incomplete ("false")canceled- Mark as canceled ("true") or active ("false")
Performance:
- Processes updates sequentially per todo
- Each todo gets all specified fields updated
- Use for 2-50 todos (for larger batches, consider chunking)
Both bugs were discovered through comprehensive edge case testing:
- String parsing validation for tag operations
- Multi-field combination testing for bulk updates
- Integration tests with real Things 3 database
Regression Prevention:
- Added unit tests for tag string parsing
- Added integration tests for multi-field bulk updates
- Validated with multiple tag/field combinations
Important: Tags must be created in Things 3 before they can be used via the API. The AI assistant cannot create tags programmatically.
# Get all available tags
tags = get_tags() # Returns count-only by default
tags = get_tags(include_items=true) # Returns full item lists
# Get todos with a specific tag
work_todos = get_tagged_items(tag="Work")
urgent_todos = get_tagged_items(tag="urgent")# Single tag
add_tags(todo_id="abc123", tags="urgent")
# Multiple tags (comma-separated, no spaces)
add_tags(todo_id="abc123", tags="work,urgent,review")
# When creating todos
add_todo(
title="Review proposal",
tags="work,urgent,review", # Comma-separated
when="today"
)
# Bulk update with tags
bulk_update_todos(
todo_ids="id1,id2,id3",
tags="urgent,Q4" # Replaces existing tags
)# Remove single tag
remove_tags(todo_id="abc123", tags="urgent")
# Remove multiple tags (comma-separated, no spaces)
remove_tags(todo_id="abc123", tags="urgent,review,old-tag")
# Tag names are case-sensitive
remove_tags(todo_id="abc123", tags="Work") # Removes "Work"
remove_tags(todo_id="abc123", tags="work") # Removes "work" (different tag)-
Check Available Tags First:
# See what tags exist tags = get_tags() # If tag doesn't exist, ask user to create it in Things 3
-
Format Requirements:
- Use comma separation:
"tag1,tag2,tag3" - No spaces after commas:
"work,urgent"not"work, urgent" - Case-sensitive:
"Work"≠"work"
- Use comma separation:
-
Tag Filtering:
- Non-existent tags are silently filtered (no error)
- Only existing tags will be added/removed
- Use
get_tags()to validate tags exist
-
Tag Search:
# Search by tag search_advanced(tag="urgent", status="incomplete") # Get all items with specific tag get_tagged_items(tag="work")
When working with retrieval tools (get_todos, search_todos, list tools), use the mode parameter for optimal context usage:
Available Modes:
auto- Automatically selects optimal mode based on data size (recommended for unknown datasets)summary- Returns count and preview only (best for large collections)minimal- Returns essential fields only (IDs, titles, status)standard- Returns common fields (default for most operations)detailed- Returns all fields (use only when needed)raw- Returns unfiltered data
Workflow Examples:
-
Daily Review
get_today(mode='standard', limit=20) -
Project Analysis
# First get overview get_todos(project_uuid='...', mode='summary') # Then drill down to specifics get_todos(project_uuid='...', mode='detailed', limit=10) -
Bulk Operations
# Get IDs efficiently search_todos(query='overdue', mode='minimal', limit=100) # Perform bulk update bulk_update_todos(todo_ids='...', completed='true')
- Standard mode: ~1KB per item
- Minimal mode: ~50 bytes per item
- Summary mode: Fixed ~200 bytes total
- For 100+ items, always start with
mode='summary'ormode='minimal'
-
Use specific list tools instead of filtering
get_todos:get_today()is faster thanget_todos()with date filteringget_tagged_items(tag='work')is faster than searching
-
Batch operations when possible:
- Use
bulk_update_todosfor multiple todos (supports multi-field updates) - Use
bulk_move_recordsinstead of multiplemove_recordcalls - Optimal batch size: 2-50 todos per operation
- Use
-
Multi-field bulk updates (efficient for large updates):
# Update multiple fields in one operation bulk_update_todos( todo_ids="id1,id2,id3,id4,id5", tags="urgent,Q4", when="today", notes="Updated in batch review" )
Things 3 supports a 4-level hierarchy:
Areas (Life/Work Domains)
└── Projects (Time-bound outcomes)
└── Todos (Action items)
└── Checklist Items (Sub-tasks)
Areas represent life domains (Work, Personal, Learning, etc.):
# Get all areas
areas = get_areas(mode='summary') # Quick overview
areas = get_areas(mode='standard') # Full list
areas = get_areas(include_items=true, mode='detailed') # With projects and todos
# Create project in specific area
add_project(
title="New Project",
area_id="abc123", # Recommended - more reliable
deadline="2025-12-31"
)
# Or use area name
add_project(
title="New Project",
area_title="Personal", # Convenient but requires unique names
deadline="2025-12-31"
)Projects are time-bound outcomes with associated tasks:
# Create project
project_id = add_project(
title="Website Redesign",
area_title="Work",
deadline="2025-12-31",
tags="high-priority,design",
notes="Complete redesign of company website"
)
# Add todos to project (must be done separately)
add_todo(title="Research competitors", list_id=project_id, heading="Research")
add_todo(title="Create wireframes", list_id=project_id, heading="Design")
add_todo(title="Implement homepage", list_id=project_id, heading="Development")
# Update project
update_project(
id=project_id,
deadline="2026-01-15",
tags="urgent,design,review-needed"
)
# Get projects
get_projects(mode='summary') # Count and preview
get_projects(mode='minimal') # IDs and names only
get_projects(mode='standard') # Full details# Move single todo
move_record(
todo_id="todo123",
destination_list="project:project456"
)
# Move multiple todos (bulk operation - much faster)
bulk_move_records(
todo_ids="todo1,todo2,todo3",
destination="project:project456",
preserve_scheduling=true
)| Target | Format | Example |
|---|---|---|
| Inbox | "inbox" |
move_record(todo_id="123", destination_list="inbox") |
| Today | "today" |
move_record(todo_id="123", destination_list="today") |
| Anytime | "anytime" |
move_record(todo_id="123", destination_list="anytime") |
| Someday | "someday" |
move_record(todo_id="123", destination_list="someday") |
| Project | "project:{id}" |
move_record(todo_id="123", destination_list="project:xyz") |
The get_todos() function supports filtering by completion status:
# Get incomplete todos (default behavior)
get_todos(project_uuid="abc123")
get_todos(project_uuid="abc123", status="incomplete")
# Get ALL todos (completed + incomplete + canceled)
get_todos(project_uuid="abc123", status=None)
# Get only completed todos
get_todos(project_uuid="abc123", status="completed")
# Get only canceled todos
get_todos(project_uuid="abc123", status="canceled")
# Works without project filter too
get_todos(status="completed") # All completed todos
get_todos(status=None) # All todos regardless of statusStatus Parameter Options:
'incomplete'(default) - Only active, uncompleted todos'completed'- Only completed todos'canceled'- Only canceled todosNone- All todos regardless of status
This feature is useful for:
- Reviewing completed work in a project
- Analyzing canceled todos
- Getting complete project history
- Status-based reporting and analytics
Checklist items are now fully supported via the Things 3 URL scheme API. The server automatically uses the URL scheme when checklist items are provided.
# Create todo with checklist items
add_todo(
title="Grocery Shopping",
notes="Weekly shopping list",
checklist_items=["Milk", "Bread", "Eggs", "Butter"], # List of strings
when="today"
)
# With project and tags
add_todo(
title="Release v2.0",
checklist_items=["Run tests", "Update docs", "Create changelog", "Tag release"],
list_id="project123",
tags="work,release",
deadline="2025-12-31"
)# Add items to existing todo (appends to end)
add_checklist_items(
todo_id="abc123",
items=["New item 1", "New item 2"]
)
# Prepend items to beginning
prepend_checklist_items(
todo_id="abc123",
items=["Urgent item", "High priority"]
)
# Replace all checklist items
replace_checklist_items(
todo_id="abc123",
items=["Item 1", "Item 2", "Item 3"]
)
# Clear all checklist items
replace_checklist_items(
todo_id="abc123",
items=[] # Empty list clears checklist
)Format Requirements:
- Items are passed as a list of strings:
["item1", "item2", "item3"] - Maximum 100 checklist items per todo
- Items can be marked complete/incomplete in Things 3 UI
Implementation Details:
- Checklists use Things URL scheme API (not AppleScript)
- URL scheme is automatically used when
checklist_itemsparameter is provided - Todo ID is retrieved after creation by searching for the newly created todo
- Non-checklist todos still use faster AppleScript approach
- Project include_items context explosion:
⚠️ NEVER useget_projects(include_items=true)- generates 252K+ tokens for 73 projects, exceeding context limits. Always useget_projects(mode='summary')first, then query specific projects.
Workarounds:
- Use
get_projects(mode='minimal')to get IDs, then query specific projects - Never use
include_items=true- causes context overflow
- Use areas for life domains (Work, Personal, Learning)
- Use projects for time-bound outcomes with clear deadlines
- Use headings within projects to organize phases
- Start with
mode='summary'for large project lists - Use
area_idinstead ofarea_titlefor reliability - Batch todo moves with
bulk_move_records() - Create tags in Things 3 before using in API
For Complete Details: See PROJECTS_AREAS_TEST_REPORT.md and HIERARCHY_QUICK_REFERENCE.md
-
Tags must exist - AI cannot create tags automatically
- Use
get_tags()to see available tags - Ask user to create new tags if needed
- Tag names are case-sensitive:
"Work"≠"work" - Use comma-separated format:
"tag1,tag2"not"tag1, tag2"
- Use
-
Date formats - Use consistent formats:
- Dates:
YYYY-MM-DDor'today','tomorrow','someday'
- Dates:
-
Limits - Respect parameter limits:
- Search results: max 500
- Logbook: max 100
- Date ranges: max 365 days
- Bulk operations: optimal 2-50 todos
-
Bulk operations - Multi-field updates:
- All specified fields are applied to each todo
- Fields: title, notes, when, deadline, tags, completed, canceled
- Format IDs as comma-separated:
"id1,id2,id3"
Problem: Spaces in comma-separated tags
# ❌ WRONG - includes spaces
add_tags(todo_id="123", tags="work, urgent, review")
# ✅ CORRECT - no spaces
add_tags(todo_id="123", tags="work,urgent,review")Problem: Inconsistent tag capitalization
# These are THREE DIFFERENT tags in Things 3:
add_tags(todo_id="123", tags="Work") # Tag: "Work"
add_tags(todo_id="123", tags="work") # Tag: "work"
add_tags(todo_id="123", tags="WORK") # Tag: "WORK"
# ✅ SOLUTION: Use consistent capitalization
# Check existing tags first:
tags = get_tags()
# Then use exact match
add_tags(todo_id="123", tags="Work")Problem: Trying to use tags that don't exist
# ❌ Tag doesn't exist - silently ignored
add_todo(title="Task", tags="nonexistent-tag")
# ✅ CORRECT: Check tags first, create if needed
tags = get_tags()
# If tag missing, ask user:
# "The tag 'project-x' doesn't exist. Please create it in Things 3 first."Problem: Assuming field order matters (it doesn't)
# ✅ Both work identically - all fields applied
bulk_update_todos(todo_ids="1,2,3", tags="urgent", when="today")
bulk_update_todos(todo_ids="1,2,3", when="today", tags="urgent")
# All specified fields are applied to each todoProblem: Using single updates when bulk would be faster
# ❌ SLOW - multiple API calls
for todo_id in ["1", "2", "3"]:
update_todo(id=todo_id, tags="urgent")
update_todo(id=todo_id, when="today")
# ✅ FAST - single bulk operation
bulk_update_todos(
todo_ids="1,2,3",
tags="urgent",
when="today"
)Best Practice: Use the todos parameter for efficient project creation with initial tasks
# ✅ RECOMMENDED: Create project with todos in one call
project_id = add_project(
title="My Project",
deadline="2025-12-31",
todos="Task 1\nTask 2\nTask 3" # Creates all 3 todos!
)
# ✅ ALTERNATIVE: Add todos separately (useful for dynamic lists)
project_id = add_project(title="My Project", deadline="2025-12-31")
add_todo(title="Task 1", list_id=project_id)
add_todo(title="Task 2", list_id=project_id)
add_todo(title="Task 3", list_id=project_id)Note: The todos parameter accepts newline-separated todo titles and creates them atomically with the project.
Problem: Retrieving too much data at once
# ❌ BAD - retrieves all todos with full details
all_todos = get_todos(mode='detailed') # Could be 1000+ items
# ✅ GOOD - use summary first, then drill down
summary = get_todos(mode='summary') # Just count and preview
# Then get specific subset:
today = get_today(mode='standard', limit=20)- Make frequent, small commits
- Use clear commit messages
- Run tests before committing
- Update documentation for API changes
When creating a new release, follow these steps to ensure version consistency across all files:
Critical Files (MUST update):
# 1. Update package version
# File: pyproject.toml (line 7)
version = "X.Y.Z"
# 2. Update runtime version
# File: src/things_mcp/__init__.py (line 3)
__version__ = "X.Y.Z"
# 3. Update CHANGELOG
# File: CHANGELOG.md (top of file)
## [X.Y.Z] - YYYY-MM-DD
### Fixed
- Bug fix description
### Added
- New feature description
### Changed
- Change description# Run tests first
pytest
# Commit changes
git add pyproject.toml src/things_mcp/__init__.py CHANGELOG.md
git commit -m "Release vX.Y.Z - Brief description"
# Push to GitHub
git push origin main
# Create and push tag
git tag vX.Y.Z
git push origin vX.Y.Z# Create release with notes from CHANGELOG
gh release create vX.Y.Z \
--title "vX.Y.Z - Release Title" \
--notes "$(sed -n '/## \[X.Y.Z\]/,/## \[/p' CHANGELOG.md | head -n -1)"# Build distribution packages
python -m build
# Upload to PyPI
python -m twine upload dist/mcp_server_things-X.Y.Z*- pyproject.toml - Package version for pip/PyPI
- src/things_mcp/init.py - Runtime version (used by server.py)
- CHANGELOG.md - Version history with dates
- Version is automatically synced:
__version__is imported by server.py and reported viaget_server_capabilities() - No need to update version in documentation examples (README.md, CONTRIBUTING.md) - those are placeholders
- All tests pass (
pytest) - Version updated in
pyproject.toml - Version updated in
src/things_mcp/__init__.py - CHANGELOG.md updated with date and changes
- Committed with descriptive message
- Pushed to GitHub
- Git tag created and pushed
- GitHub release created
- Published to PyPI
- Verify version reporting: AI should report correct version when queried
Status: Planning Phase
Document: docs/REFACTORING_PLAN.md
A comprehensive 10-week, 8-phase refactoring plan has been created to improve code quality:
Current Issues:
- 5 bare
except:blocks hiding errors - 19 functions >100 lines (largest: 214 lines)
- 4 files >1,300 lines (largest: 1,657 lines)
- 31 duplicate AppleScript invocations
- Complex 193-line string parser
Target Improvements:
- Zero bare except blocks (specific exception types + logging)
- All functions <100 lines (target: 80)
- All files <1,000 lines (target: 500)
- Consolidated AppleScript patterns via templates
- State machine-based parser
Phased Approach:
- Phase 1 (Week 1): Fix bare except blocks - LOW RISK
- Phase 2 (Weeks 2-3): Parser refactoring - HIGH RISK, feature-flagged
- Phase 3 (Weeks 4-5): Function decomposition - MEDIUM RISK
- Phase 4 (Week 6): File organization - MEDIUM RISK
- Phase 5 (Week 7): Consolidate AppleScript patterns - LOW RISK
- Phase 6 (Week 8): Error handling improvements - LOW RISK
- Phase 7 (Week 9): Documentation - LOW RISK
- Phase 8 (Week 10): Performance testing - LOW RISK
Constraints:
- ✅ 100% backwards compatibility (no breaking changes)
- ✅ All 330+ tests must continue to pass
- ✅ No performance regressions >10%
- ✅ Incremental commits (each passes tests)
For Swarm Implementation:
- See
docs/REFACTORING_PLAN.mdfor detailed task breakdown - Each phase has specific deliverables and validation steps
- Parallel execution possible for Phase 1, 3, 4 tasks
- Feature flags for high-risk changes (Phase 2)
When implementing refactoring tasks, always:
- Read the detailed task specification in REFACTORING_PLAN.md
- Run tests before making changes
- Make minimal, focused changes
- Run full test suite after changes
- Commit only if all tests pass
- Never hardcode authentication tokens
- Keep root directory clean (use appropriate subdirectories)
- Prefer editing existing files over creating new ones
- Test with actual Things 3 before marking features complete
- When we add new capabilities, we need to always be sure to "advertise them" to the AI using the MCP server