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,633 @@
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("Things")
34
+
35
+ # LIST VIEWS
36
+
37
+ @mcp.tool(name="get-inbox")
38
+ def get_inbox() -> str:
39
+ """Get todos from Inbox"""
40
+ import time
41
+ start_time = time.time()
42
+ log_operation_start("get-inbox")
43
+
44
+ try:
45
+ todos = things.inbox()
46
+
47
+ if not todos:
48
+ log_operation_end("get-inbox", True, time.time() - start_time, count=0)
49
+ return "No items found in Inbox"
50
+
51
+ formatted_todos = [format_todo(todo) for todo in todos]
52
+ log_operation_end("get-inbox", True, time.time() - start_time, count=len(todos))
53
+ return "\n\n---\n\n".join(formatted_todos)
54
+ except Exception as e:
55
+ log_operation_end("get-inbox", False, time.time() - start_time, error=str(e))
56
+ raise
57
+
58
+ @mcp.tool(name="get-today")
59
+ @cached(ttl=CACHE_TTL.get("today", 30))
60
+ def get_today() -> str:
61
+ """Get todos due today"""
62
+ import time
63
+ start_time = time.time()
64
+ log_operation_start("get-today")
65
+
66
+ try:
67
+ todos = things.today()
68
+
69
+ if not todos:
70
+ log_operation_end("get-today", True, time.time() - start_time, count=0)
71
+ return "No items due today"
72
+
73
+ formatted_todos = [format_todo(todo) for todo in todos]
74
+ log_operation_end("get-today", True, time.time() - start_time, count=len(todos))
75
+ return "\n\n---\n\n".join(formatted_todos)
76
+ except Exception as e:
77
+ log_operation_end("get-today", False, time.time() - start_time, error=str(e))
78
+ raise
79
+
80
+ @mcp.tool(name="get-upcoming")
81
+ def get_upcoming() -> str:
82
+ """Get upcoming todos"""
83
+ todos = things.upcoming()
84
+
85
+ if not todos:
86
+ return "No upcoming items"
87
+
88
+ formatted_todos = [format_todo(todo) for todo in todos]
89
+ return "\n\n---\n\n".join(formatted_todos)
90
+
91
+ @mcp.tool(name="get-anytime")
92
+ def get_anytime() -> str:
93
+ """Get todos from Anytime list"""
94
+ todos = things.anytime()
95
+
96
+ if not todos:
97
+ return "No items in Anytime list"
98
+
99
+ formatted_todos = [format_todo(todo) for todo in todos]
100
+ return "\n\n---\n\n".join(formatted_todos)
101
+
102
+ @mcp.tool(name="get-someday")
103
+ def get_someday() -> str:
104
+ """Get todos from Someday list"""
105
+ todos = things.someday()
106
+
107
+ if not todos:
108
+ return "No items in Someday list"
109
+
110
+ formatted_todos = [format_todo(todo) for todo in todos]
111
+ return "\n\n---\n\n".join(formatted_todos)
112
+
113
+ @mcp.tool(name="get-logbook")
114
+ def get_logbook(period: str = "7d", limit: int = 50) -> str:
115
+ """
116
+ Get completed todos from Logbook, defaults to last 7 days
117
+
118
+ Args:
119
+ period: Time period to look back (e.g., '3d', '1w', '2m', '1y'). Defaults to '7d'
120
+ limit: Maximum number of entries to return. Defaults to 50
121
+ """
122
+ todos = things.last(period, status='completed')
123
+
124
+ if not todos:
125
+ return "No completed items found"
126
+
127
+ if todos and len(todos) > limit:
128
+ todos = todos[:limit]
129
+
130
+ formatted_todos = [format_todo(todo) for todo in todos]
131
+ return "\n\n---\n\n".join(formatted_todos)
132
+
133
+ @mcp.tool(name="get-trash")
134
+ def get_trash() -> str:
135
+ """Get trashed todos"""
136
+ todos = things.trash()
137
+
138
+ if not todos:
139
+ return "No items in trash"
140
+
141
+ formatted_todos = [format_todo(todo) for todo in todos]
142
+ return "\n\n---\n\n".join(formatted_todos)
143
+
144
+ # BASIC TODO OPERATIONS
145
+
146
+ @mcp.tool(name="get-todos")
147
+ def get_todos(project_uuid: Optional[str] = None, include_items: bool = True) -> str:
148
+ """
149
+ Get todos from Things, optionally filtered by project
150
+
151
+ Args:
152
+ project_uuid: Optional UUID of a specific project to get todos from
153
+ include_items: Include checklist items
154
+ """
155
+ if project_uuid:
156
+ project = things.get(project_uuid)
157
+ if not project or project.get('type') != 'project':
158
+ return f"Error: Invalid project UUID '{project_uuid}'"
159
+
160
+ todos = things.todos(project=project_uuid, start=None)
161
+
162
+ if not todos:
163
+ return "No todos found"
164
+
165
+ formatted_todos = [format_todo(todo) for todo in todos]
166
+ return "\n\n---\n\n".join(formatted_todos)
167
+
168
+ @mcp.tool(name="get-projects")
169
+ def get_projects(include_items: bool = False) -> str:
170
+ """
171
+ Get all projects from Things
172
+
173
+ Args:
174
+ include_items: Include tasks within projects
175
+ """
176
+ projects = things.projects()
177
+
178
+ if not projects:
179
+ return "No projects found"
180
+
181
+ formatted_projects = [format_project(project, include_items) for project in projects]
182
+ return "\n\n---\n\n".join(formatted_projects)
183
+
184
+ @mcp.tool(name="get-areas")
185
+ def get_areas(include_items: bool = False) -> str:
186
+ """
187
+ Get all areas from Things
188
+
189
+ Args:
190
+ include_items: Include projects and tasks within areas
191
+ """
192
+ areas = things.areas()
193
+
194
+ if not areas:
195
+ return "No areas found"
196
+
197
+ formatted_areas = [format_area(area, include_items) for area in areas]
198
+ return "\n\n---\n\n".join(formatted_areas)
199
+
200
+ # TAG OPERATIONS
201
+
202
+ @mcp.tool(name="get-tags")
203
+ def get_tags(include_items: bool = False) -> str:
204
+ """
205
+ Get all tags
206
+
207
+ Args:
208
+ include_items: Include items tagged with each tag
209
+ """
210
+ tags = things.tags()
211
+
212
+ if not tags:
213
+ return "No tags found"
214
+
215
+ formatted_tags = [format_tag(tag, include_items) for tag in tags]
216
+ return "\n\n---\n\n".join(formatted_tags)
217
+
218
+ @mcp.tool(name="get-tagged-items")
219
+ def get_tagged_items(tag: str) -> str:
220
+ """
221
+ Get items with a specific tag
222
+
223
+ Args:
224
+ tag: Tag title to filter by
225
+ """
226
+ todos = things.todos(tag=tag)
227
+
228
+ if not todos:
229
+ return f"No items found with tag '{tag}'"
230
+
231
+ formatted_todos = [format_todo(todo) for todo in todos]
232
+ return "\n\n---\n\n".join(formatted_todos)
233
+
234
+ # SEARCH OPERATIONS
235
+
236
+ @mcp.tool(name="search-todos")
237
+ def search_todos(query: str) -> str:
238
+ """
239
+ Search todos by title or notes
240
+
241
+ Args:
242
+ query: Search term to look for in todo titles and notes
243
+ """
244
+ todos = things.search(query)
245
+
246
+ if not todos:
247
+ return f"No todos found matching '{query}'"
248
+
249
+ formatted_todos = [format_todo(todo) for todo in todos]
250
+ return "\n\n---\n\n".join(formatted_todos)
251
+
252
+ @mcp.tool(name="search-advanced")
253
+ def search_advanced(
254
+ status: Optional[str] = None,
255
+ start_date: Optional[str] = None,
256
+ deadline: Optional[str] = None,
257
+ tag: Optional[str] = None,
258
+ area: Optional[str] = None,
259
+ type: Optional[str] = None
260
+ ) -> str:
261
+ """
262
+ Advanced todo search with multiple filters
263
+
264
+ Args:
265
+ status: Filter by todo status (incomplete/completed/canceled)
266
+ start_date: Filter by start date (YYYY-MM-DD)
267
+ deadline: Filter by deadline (YYYY-MM-DD)
268
+ tag: Filter by tag
269
+ area: Filter by area UUID
270
+ type: Filter by item type (to-do/project/heading)
271
+ """
272
+ # Build filter parameters
273
+ kwargs = {}
274
+
275
+ # Add filters that are provided
276
+ if status:
277
+ kwargs['status'] = status
278
+ if deadline:
279
+ kwargs['deadline'] = deadline
280
+ if start_date:
281
+ kwargs['start'] = start_date
282
+ if tag:
283
+ kwargs['tag'] = tag
284
+ if area:
285
+ kwargs['area'] = area
286
+ if type:
287
+ kwargs['type'] = type
288
+
289
+ # Execute search with applicable filters
290
+ try:
291
+ todos = things.todos(**kwargs)
292
+
293
+ if not todos:
294
+ return "No items found matching your search criteria"
295
+
296
+ formatted_todos = [format_todo(todo) for todo in todos]
297
+ return "\n\n---\n\n".join(formatted_todos)
298
+ except Exception as e:
299
+ return f"Error in advanced search: {str(e)}"
300
+
301
+ # MODIFICATION OPERATIONS
302
+
303
+ @mcp.tool(name="add-todo")
304
+ def add_task(
305
+ title: str,
306
+ notes: Optional[str] = None,
307
+ when: Optional[str] = None,
308
+ deadline: Optional[str] = None,
309
+ tags: Optional[List[str]] = None,
310
+ checklist_items: Optional[List[str]] = None,
311
+ list_id: Optional[str] = None,
312
+ list_title: Optional[str] = None,
313
+ heading: Optional[str] = None
314
+ ) -> str:
315
+ """
316
+ Create a new todo in Things
317
+
318
+ Args:
319
+ title: Title of the todo
320
+ notes: Notes for the todo
321
+ when: When to schedule the todo (today, tomorrow, evening, anytime, someday, or YYYY-MM-DD)
322
+ deadline: Deadline for the todo (YYYY-MM-DD)
323
+ tags: Tags to apply to the todo
324
+ checklist_items: Checklist items to add
325
+ list_id: ID of project/area to add to
326
+ list_title: Title of project/area to add to
327
+ heading: Heading to add under
328
+ """
329
+ try:
330
+ # Ensure Things app is running
331
+ if not app_state.update_app_state():
332
+ if not launch_things():
333
+ return "Error: Unable to launch Things app"
334
+
335
+ # Execute the add_todo URL command
336
+ result = add_todo(
337
+ title=title,
338
+ notes=notes,
339
+ when=when,
340
+ deadline=deadline,
341
+ tags=tags,
342
+ checklist_items=checklist_items,
343
+ list_id=list_id,
344
+ list_title=list_title,
345
+ heading=heading
346
+ )
347
+
348
+ if not result:
349
+ return "Error: Failed to create todo"
350
+
351
+ # Invalidate relevant caches after creating a todo
352
+ invalidate_caches_for(["get-inbox", "get-today", "get-upcoming", "get-todos"])
353
+
354
+ return f"Successfully created todo: {title}"
355
+ except Exception as e:
356
+ logger.error(f"Error creating todo: {str(e)}")
357
+ return f"Error creating todo: {str(e)}"
358
+
359
+ @mcp.tool(name="add-project")
360
+ def add_new_project(
361
+ title: str,
362
+ notes: Optional[str] = None,
363
+ when: Optional[str] = None,
364
+ deadline: Optional[str] = None,
365
+ tags: Optional[List[str]] = None,
366
+ area_id: Optional[str] = None,
367
+ area_title: Optional[str] = None,
368
+ todos: Optional[List[str]] = None
369
+ ) -> str:
370
+ """
371
+ Create a new project in Things
372
+
373
+ Args:
374
+ title: Title of the project
375
+ notes: Notes for the project
376
+ when: When to schedule the project
377
+ deadline: Deadline for the project
378
+ tags: Tags to apply to the project
379
+ area_id: ID of area to add to
380
+ area_title: Title of area to add to
381
+ todos: Initial todos to create in the project
382
+ """
383
+ try:
384
+ # Ensure Things app is running
385
+ if not app_state.update_app_state():
386
+ if not launch_things():
387
+ return "Error: Unable to launch Things app"
388
+
389
+ # Execute the add_project URL command
390
+ result = add_project(
391
+ title=title,
392
+ notes=notes,
393
+ when=when,
394
+ deadline=deadline,
395
+ tags=tags,
396
+ area_id=area_id,
397
+ area_title=area_title,
398
+ todos=todos
399
+ )
400
+
401
+ if not result:
402
+ return "Error: Failed to create project"
403
+
404
+ return f"Successfully created project: {title}"
405
+ except Exception as e:
406
+ logger.error(f"Error creating project: {str(e)}")
407
+ return f"Error creating project: {str(e)}"
408
+
409
+ @mcp.tool(name="update-todo")
410
+ def update_task(
411
+ id: str,
412
+ title: Optional[str] = None,
413
+ notes: Optional[str] = None,
414
+ when: Optional[str] = None,
415
+ deadline: Optional[str] = None,
416
+ tags: Optional[List[str]] = None,
417
+ completed: Optional[bool] = None,
418
+ canceled: Optional[bool] = None
419
+ ) -> str:
420
+ """
421
+ Update an existing todo in Things
422
+
423
+ Args:
424
+ id: ID of the todo to update
425
+ title: New title
426
+ notes: New notes
427
+ when: New schedule
428
+ deadline: New deadline
429
+ tags: New tags
430
+ completed: Mark as completed
431
+ canceled: Mark as canceled
432
+ """
433
+ try:
434
+ # Ensure Things app is running
435
+ if not app_state.update_app_state():
436
+ if not launch_things():
437
+ return "Error: Unable to launch Things app"
438
+
439
+ # Execute the update_todo URL command
440
+ result = update_todo(
441
+ id=id,
442
+ title=title,
443
+ notes=notes,
444
+ when=when,
445
+ deadline=deadline,
446
+ tags=tags,
447
+ completed=completed,
448
+ canceled=canceled
449
+ )
450
+
451
+ if not result:
452
+ return "Error: Failed to update todo"
453
+
454
+ return f"Successfully updated todo with ID: {id}"
455
+ except Exception as e:
456
+ logger.error(f"Error updating todo: {str(e)}")
457
+ return f"Error updating todo: {str(e)}"
458
+
459
+ @mcp.tool(name="update-project")
460
+ def update_existing_project(
461
+ id: str,
462
+ title: Optional[str] = None,
463
+ notes: Optional[str] = None,
464
+ when: Optional[str] = None,
465
+ deadline: Optional[str] = None,
466
+ tags: Optional[List[str]] = None,
467
+ completed: Optional[bool] = None,
468
+ canceled: Optional[bool] = None
469
+ ) -> str:
470
+ """
471
+ Update an existing project in Things
472
+
473
+ Args:
474
+ id: ID of the project to update
475
+ title: New title
476
+ notes: New notes
477
+ when: New schedule
478
+ deadline: New deadline
479
+ tags: New tags
480
+ completed: Mark as completed
481
+ canceled: Mark as canceled
482
+ """
483
+ try:
484
+ # Ensure Things app is running
485
+ if not app_state.update_app_state():
486
+ if not launch_things():
487
+ return "Error: Unable to launch Things app"
488
+
489
+ # Execute the update_project URL command
490
+ result = update_project(
491
+ id=id,
492
+ title=title,
493
+ notes=notes,
494
+ when=when,
495
+ deadline=deadline,
496
+ tags=tags,
497
+ completed=completed,
498
+ canceled=canceled
499
+ )
500
+
501
+ if not result:
502
+ return "Error: Failed to update project"
503
+
504
+ return f"Successfully updated project with ID: {id}"
505
+ except Exception as e:
506
+ logger.error(f"Error updating project: {str(e)}")
507
+ return f"Error updating project: {str(e)}"
508
+
509
+ @mcp.tool(name="show-item")
510
+ def show_item(
511
+ id: str,
512
+ query: Optional[str] = None,
513
+ filter_tags: Optional[List[str]] = None
514
+ ) -> str:
515
+ """
516
+ Show a specific item or list in Things
517
+
518
+ Args:
519
+ id: ID of item to show, or one of: inbox, today, upcoming, anytime, someday, logbook
520
+ query: Optional query to filter by
521
+ filter_tags: Optional tags to filter by
522
+ """
523
+ try:
524
+ # Ensure Things app is running
525
+ if not app_state.update_app_state():
526
+ if not launch_things():
527
+ return "Error: Unable to launch Things app"
528
+
529
+ # Execute the show URL command
530
+ result = show(
531
+ id=id,
532
+ query=query,
533
+ filter_tags=filter_tags
534
+ )
535
+
536
+ if not result:
537
+ return f"Error: Failed to show item/list '{id}'"
538
+
539
+ return f"Successfully opened '{id}' in Things"
540
+ except Exception as e:
541
+ logger.error(f"Error showing item: {str(e)}")
542
+ return f"Error showing item: {str(e)}"
543
+
544
+ @mcp.tool(name="search-items")
545
+ def search_all_items(query: str) -> str:
546
+ """
547
+ Search for items in Things
548
+
549
+ Args:
550
+ query: Search query
551
+ """
552
+ try:
553
+ # Ensure Things app is running
554
+ if not app_state.update_app_state():
555
+ if not launch_things():
556
+ return "Error: Unable to launch Things app"
557
+
558
+ # Execute the search URL command
559
+ result = search(query=query)
560
+
561
+ if not result:
562
+ return f"Error: Failed to search for '{query}'"
563
+
564
+ return f"Successfully searched for '{query}' in Things"
565
+ except Exception as e:
566
+ logger.error(f"Error searching: {str(e)}")
567
+ return f"Error searching: {str(e)}"
568
+
569
+ @mcp.tool(name="get-recent")
570
+ def get_recent(period: str) -> str:
571
+ """
572
+ Get recently created items
573
+
574
+ Args:
575
+ period: Time period (e.g., '3d', '1w', '2m', '1y')
576
+ """
577
+ try:
578
+ # Check if period format is valid
579
+ if not period or not any(period.endswith(unit) for unit in ['d', 'w', 'm', 'y']):
580
+ return "Error: Period must be in format '3d', '1w', '2m', '1y'"
581
+
582
+ # Get recent items
583
+ items = things.last(period)
584
+
585
+ if not items:
586
+ return f"No items found in the last {period}"
587
+
588
+ formatted_items = []
589
+ for item in items:
590
+ if item.get('type') == 'to-do':
591
+ formatted_items.append(format_todo(item))
592
+ elif item.get('type') == 'project':
593
+ formatted_items.append(format_project(item, include_items=False))
594
+
595
+ return "\n\n---\n\n".join(formatted_items)
596
+ except Exception as e:
597
+ logger.error(f"Error getting recent items: {str(e)}")
598
+ return f"Error getting recent items: {str(e)}"
599
+
600
+ @mcp.tool(name="get-cache-stats")
601
+ def get_cache_statistics() -> str:
602
+ """Get cache performance statistics"""
603
+ stats = get_cache_stats()
604
+
605
+ return f"""Cache Statistics:
606
+ - Total entries: {stats['entries']}
607
+ - Cache hits: {stats['hits']}
608
+ - Cache misses: {stats['misses']}
609
+ - Hit rate: {stats['hit_rate']}
610
+ - Total requests: {stats['total_requests']}"""
611
+
612
+ # Main entry point
613
+ def run_things_mcp_server():
614
+ """Run the Things MCP server"""
615
+ # Check if Things app is available
616
+ if not app_state.update_app_state():
617
+ logger.warning("Things app is not running at startup. MCP will attempt to launch it when needed.")
618
+ try:
619
+ # Try to launch Things
620
+ if launch_things():
621
+ logger.info("Successfully launched Things app")
622
+ else:
623
+ logger.error("Unable to launch Things app. Some operations may fail.")
624
+ except Exception as e:
625
+ logger.error(f"Error launching Things app: {str(e)}")
626
+ else:
627
+ logger.info("Things app is running and ready for operations")
628
+
629
+ # Run the MCP server
630
+ mcp.run()
631
+
632
+ if __name__ == "__main__":
633
+ run_things_mcp_server()