iflow-mcp_excelsier-things3-enhanced-mcp 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,687 @@
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()