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.
things_mcp/handlers.py ADDED
@@ -0,0 +1,601 @@
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)}")]