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.
- iflow_mcp_excelsier_things3_enhanced_mcp-1.0.0.dist-info/METADATA +444 -0
- iflow_mcp_excelsier_things3_enhanced_mcp-1.0.0.dist-info/RECORD +20 -0
- iflow_mcp_excelsier_things3_enhanced_mcp-1.0.0.dist-info/WHEEL +4 -0
- iflow_mcp_excelsier_things3_enhanced_mcp-1.0.0.dist-info/entry_points.txt +2 -0
- iflow_mcp_excelsier_things3_enhanced_mcp-1.0.0.dist-info/licenses/LICENSE +24 -0
- things_mcp/__init__.py +5 -0
- things_mcp/applescript_bridge.py +335 -0
- things_mcp/cache.py +240 -0
- things_mcp/config.py +120 -0
- things_mcp/fast_server.py +633 -0
- things_mcp/formatters.py +128 -0
- things_mcp/handlers.py +601 -0
- things_mcp/logging_config.py +218 -0
- things_mcp/mcp_tools.py +465 -0
- things_mcp/simple_server.py +687 -0
- things_mcp/simple_url_scheme.py +230 -0
- things_mcp/tag_handler.py +111 -0
- things_mcp/things_server.py +106 -0
- things_mcp/url_scheme.py +318 -0
- things_mcp/utils.py +360 -0
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)}")]
|