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
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
import subprocess
|
|
3
|
+
import logging
|
|
4
|
+
from typing import Optional, List, Dict, Any, Union
|
|
5
|
+
|
|
6
|
+
logger = logging.getLogger(__name__)
|
|
7
|
+
|
|
8
|
+
def run_applescript(script: str) -> Union[str, bool]:
|
|
9
|
+
"""Run an AppleScript command and return the result.
|
|
10
|
+
|
|
11
|
+
Args:
|
|
12
|
+
script: The AppleScript code to execute
|
|
13
|
+
|
|
14
|
+
Returns:
|
|
15
|
+
The result of the AppleScript execution, or False if it failed
|
|
16
|
+
"""
|
|
17
|
+
try:
|
|
18
|
+
result = subprocess.run(['osascript', '-e', script],
|
|
19
|
+
capture_output=True, text=True)
|
|
20
|
+
|
|
21
|
+
if result.returncode != 0:
|
|
22
|
+
logger.error(f"AppleScript error: {result.stderr}")
|
|
23
|
+
return False
|
|
24
|
+
|
|
25
|
+
return result.stdout.strip()
|
|
26
|
+
except Exception as e:
|
|
27
|
+
logger.error(f"Error running AppleScript: {str(e)}")
|
|
28
|
+
return False
|
|
29
|
+
|
|
30
|
+
def add_todo_direct(title: str, notes: Optional[str] = None, when: Optional[str] = None,
|
|
31
|
+
tags: Optional[List[str]] = None, list_title: Optional[str] = None) -> str:
|
|
32
|
+
"""Add a todo to Things directly using AppleScript.
|
|
33
|
+
|
|
34
|
+
This bypasses URL schemes entirely to avoid encoding issues.
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
title: Title of the todo
|
|
38
|
+
notes: Notes for the todo
|
|
39
|
+
when: When to schedule the todo (today, tomorrow, evening, anytime, someday)
|
|
40
|
+
tags: Tags to apply to the todo
|
|
41
|
+
list_title: Name of project/area to add to
|
|
42
|
+
|
|
43
|
+
Returns:
|
|
44
|
+
ID of the created todo if successful, False otherwise
|
|
45
|
+
"""
|
|
46
|
+
# Build the AppleScript command
|
|
47
|
+
script_parts = ['tell application "Things3"']
|
|
48
|
+
|
|
49
|
+
# Create the todo with properties
|
|
50
|
+
properties = []
|
|
51
|
+
properties.append(f'name:"{escape_applescript_string(title)}"')
|
|
52
|
+
|
|
53
|
+
if notes:
|
|
54
|
+
properties.append(f'notes:"{escape_applescript_string(notes)}"')
|
|
55
|
+
|
|
56
|
+
# Create with properties in the right way
|
|
57
|
+
script_parts.append(f'set newTodo to make new to do with properties {{{", ".join(properties)}}}')
|
|
58
|
+
|
|
59
|
+
# Add scheduling
|
|
60
|
+
if when:
|
|
61
|
+
when_mapping = {
|
|
62
|
+
'today': '', # Default is today, no need to set
|
|
63
|
+
'tomorrow': 'set activation date of newTodo to ((current date) + 1 * days)',
|
|
64
|
+
'evening': '', # Default is today, no need to set
|
|
65
|
+
'anytime': '', # Default
|
|
66
|
+
'someday': 'set status of newTodo to someday'
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if when in when_mapping:
|
|
70
|
+
if when_mapping[when]:
|
|
71
|
+
script_parts.append(when_mapping[when])
|
|
72
|
+
else:
|
|
73
|
+
# For date handling, it's safest to just log it and not try to set it
|
|
74
|
+
# This avoids AppleScript date formatting issues
|
|
75
|
+
logger.warning(f"Custom date format '{when}' not supported, defaulting to today")
|
|
76
|
+
|
|
77
|
+
# Add tags if provided
|
|
78
|
+
if tags and len(tags) > 0:
|
|
79
|
+
for tag in tags:
|
|
80
|
+
script_parts.append(f'tell newTodo to make new tag with properties {{name:"{escape_applescript_string(tag)}"}}')
|
|
81
|
+
|
|
82
|
+
# Add to a specific project/area if specified
|
|
83
|
+
if list_title:
|
|
84
|
+
script_parts.append(f'set project_name to "{escape_applescript_string(list_title)}"')
|
|
85
|
+
script_parts.append('try')
|
|
86
|
+
script_parts.append(' set target_project to first project whose name is project_name')
|
|
87
|
+
script_parts.append(' set project of newTodo to target_project')
|
|
88
|
+
script_parts.append('on error')
|
|
89
|
+
script_parts.append(' -- Project not found, try area')
|
|
90
|
+
script_parts.append(' try')
|
|
91
|
+
script_parts.append(' set target_area to first area whose name is project_name')
|
|
92
|
+
script_parts.append(' set area of newTodo to target_area')
|
|
93
|
+
script_parts.append(' on error')
|
|
94
|
+
script_parts.append(' -- Neither project nor area found, todo will remain in inbox')
|
|
95
|
+
script_parts.append(' end try')
|
|
96
|
+
script_parts.append('end try')
|
|
97
|
+
|
|
98
|
+
# Get the ID of the created todo
|
|
99
|
+
script_parts.append('return id of newTodo')
|
|
100
|
+
|
|
101
|
+
# Close the tell block
|
|
102
|
+
script_parts.append('end tell')
|
|
103
|
+
|
|
104
|
+
# Execute the script
|
|
105
|
+
script = '\n'.join(script_parts)
|
|
106
|
+
logger.debug(f"Executing AppleScript: {script}")
|
|
107
|
+
|
|
108
|
+
result = run_applescript(script)
|
|
109
|
+
if result:
|
|
110
|
+
logger.info(f"Successfully created todo with ID: {result}")
|
|
111
|
+
return result
|
|
112
|
+
else:
|
|
113
|
+
logger.error("Failed to create todo")
|
|
114
|
+
return False
|
|
115
|
+
|
|
116
|
+
def escape_applescript_string(text: str) -> str:
|
|
117
|
+
"""Escape special characters in an AppleScript string.
|
|
118
|
+
|
|
119
|
+
Args:
|
|
120
|
+
text: The string to escape
|
|
121
|
+
|
|
122
|
+
Returns:
|
|
123
|
+
The escaped string
|
|
124
|
+
"""
|
|
125
|
+
if not text:
|
|
126
|
+
return ""
|
|
127
|
+
|
|
128
|
+
# Replace any "+" with spaces first
|
|
129
|
+
text = text.replace("+", " ")
|
|
130
|
+
|
|
131
|
+
# Escape quotes by doubling them (AppleScript style)
|
|
132
|
+
return text.replace('"', '""')
|
|
133
|
+
|
|
134
|
+
def update_todo_direct(id: str, title: Optional[str] = None, notes: Optional[str] = None,
|
|
135
|
+
when: Optional[str] = None, deadline: Optional[str] = None,
|
|
136
|
+
tags: Optional[Union[List[str], str]] = None, add_tags: Optional[Union[List[str], str]] = None,
|
|
137
|
+
checklist_items: Optional[List[str]] = None, completed: Optional[bool] = None,
|
|
138
|
+
canceled: Optional[bool] = None) -> bool:
|
|
139
|
+
"""Update a todo directly using AppleScript.
|
|
140
|
+
|
|
141
|
+
This bypasses URL schemes entirely to avoid authentication issues.
|
|
142
|
+
|
|
143
|
+
Args:
|
|
144
|
+
id: The ID of the todo to update
|
|
145
|
+
title: New title for the todo
|
|
146
|
+
notes: New notes for the todo
|
|
147
|
+
when: New schedule for the todo (today, tomorrow, evening, anytime, someday, or YYYY-MM-DD)
|
|
148
|
+
deadline: New deadline for the todo (YYYY-MM-DD)
|
|
149
|
+
tags: New tags for the todo (replaces existing tags)
|
|
150
|
+
add_tags: Tags to add to the todo (preserves existing tags)
|
|
151
|
+
checklist_items: Checklist items to set for the todo (replaces existing items)
|
|
152
|
+
completed: Mark as completed
|
|
153
|
+
canceled: Mark as canceled
|
|
154
|
+
|
|
155
|
+
Returns:
|
|
156
|
+
True if successful, False otherwise
|
|
157
|
+
"""
|
|
158
|
+
import re
|
|
159
|
+
|
|
160
|
+
# Build the AppleScript command to find and update the todo
|
|
161
|
+
script_parts = ['tell application "Things3"']
|
|
162
|
+
script_parts.append('try')
|
|
163
|
+
script_parts.append(f' set theTodo to to do id "{id}"')
|
|
164
|
+
|
|
165
|
+
# Update properties one at a time
|
|
166
|
+
if title:
|
|
167
|
+
script_parts.append(f' set name of theTodo to "{escape_applescript_string(title)}"')
|
|
168
|
+
|
|
169
|
+
if notes:
|
|
170
|
+
script_parts.append(f' set notes of theTodo to "{escape_applescript_string(notes)}"')
|
|
171
|
+
|
|
172
|
+
# Handle date-related properties
|
|
173
|
+
if when:
|
|
174
|
+
# Check if when is a date in YYYY-MM-DD format
|
|
175
|
+
is_date_format = re.match(r'^\d{4}-\d{2}-\d{2}$', when)
|
|
176
|
+
|
|
177
|
+
# Simple mapping of common 'when' values to AppleScript commands
|
|
178
|
+
if when == 'today':
|
|
179
|
+
script_parts.append(' move theTodo to list "Today"')
|
|
180
|
+
elif when == 'tomorrow':
|
|
181
|
+
script_parts.append(' set activation date of theTodo to ((current date) + (1 * days))')
|
|
182
|
+
script_parts.append(' move theTodo to list "Upcoming"')
|
|
183
|
+
elif when == 'evening':
|
|
184
|
+
script_parts.append(' move theTodo to list "Evening"')
|
|
185
|
+
elif when == 'anytime':
|
|
186
|
+
script_parts.append(' move theTodo to list "Anytime"')
|
|
187
|
+
elif when == 'someday':
|
|
188
|
+
script_parts.append(' move theTodo to list "Someday"')
|
|
189
|
+
elif is_date_format:
|
|
190
|
+
# Handle YYYY-MM-DD format dates
|
|
191
|
+
year, month, day = when.split('-')
|
|
192
|
+
script_parts.append(f'''
|
|
193
|
+
-- Set activation date with direct date string
|
|
194
|
+
set dateString to "{when}"
|
|
195
|
+
set newDate to date dateString
|
|
196
|
+
set activation date of theTodo to newDate
|
|
197
|
+
-- Move to the Upcoming list
|
|
198
|
+
move theTodo to list "Upcoming"
|
|
199
|
+
''')
|
|
200
|
+
else:
|
|
201
|
+
# For other formats, just log a warning and don't try to set it
|
|
202
|
+
logger.warning(f"Schedule format '{when}' not directly supported in this simplified version")
|
|
203
|
+
|
|
204
|
+
if deadline:
|
|
205
|
+
# Check if deadline is in YYYY-MM-DD format
|
|
206
|
+
if re.match(r'^\d{4}-\d{2}-\d{2}$', deadline):
|
|
207
|
+
year, month, day = deadline.split('-')
|
|
208
|
+
script_parts.append(f'''
|
|
209
|
+
-- Set deadline with direct date string
|
|
210
|
+
set deadlineString to "{deadline}"
|
|
211
|
+
set deadlineDate to date deadlineString
|
|
212
|
+
set deadline of theTodo to deadlineDate
|
|
213
|
+
''')
|
|
214
|
+
else:
|
|
215
|
+
logger.warning(f"Invalid deadline format: {deadline}. Expected YYYY-MM-DD")
|
|
216
|
+
|
|
217
|
+
# Handle tags (clearing and adding new ones)
|
|
218
|
+
if tags is not None:
|
|
219
|
+
# Convert string tags to list if needed
|
|
220
|
+
if isinstance(tags, str):
|
|
221
|
+
tags = [tags]
|
|
222
|
+
|
|
223
|
+
if tags:
|
|
224
|
+
# Clear existing tags first
|
|
225
|
+
script_parts.append(' -- Clear existing tags')
|
|
226
|
+
script_parts.append(' set tag_names of theTodo to {}')
|
|
227
|
+
|
|
228
|
+
# Simplified tag handling
|
|
229
|
+
import json
|
|
230
|
+
tags_json = json.dumps(tags)
|
|
231
|
+
script_parts.append(f'''
|
|
232
|
+
-- Set tags using a list
|
|
233
|
+
set tagNameList to {tags_json}
|
|
234
|
+
-- Clear existing tags
|
|
235
|
+
set oldTags to tags of theTodo
|
|
236
|
+
repeat with t from (count of oldTags) to 1 by -1
|
|
237
|
+
delete item t of oldTags
|
|
238
|
+
end repeat
|
|
239
|
+
-- Add new tags
|
|
240
|
+
repeat with t from 1 to (count of tagNameList)
|
|
241
|
+
set tagText to item t of tagNameList
|
|
242
|
+
tell theTodo
|
|
243
|
+
set newTag to make new tag
|
|
244
|
+
set name of newTag to tagText
|
|
245
|
+
end tell
|
|
246
|
+
end repeat
|
|
247
|
+
''')
|
|
248
|
+
else:
|
|
249
|
+
# Clear all tags if empty list provided
|
|
250
|
+
script_parts.append(' -- Clear all tags')
|
|
251
|
+
script_parts.append(' set tag_names of theTodo to {}')
|
|
252
|
+
|
|
253
|
+
# Handle adding tags without replacing existing ones
|
|
254
|
+
if add_tags is not None:
|
|
255
|
+
# Convert string to list if needed
|
|
256
|
+
if isinstance(add_tags, str):
|
|
257
|
+
add_tags = [add_tags]
|
|
258
|
+
|
|
259
|
+
for tag in add_tags:
|
|
260
|
+
tag_name = escape_applescript_string(tag)
|
|
261
|
+
script_parts.append(f'''
|
|
262
|
+
-- Add tag {tag_name} if it doesn't exist
|
|
263
|
+
set tagFound to false
|
|
264
|
+
repeat with t in tags of theTodo
|
|
265
|
+
if name of t is "{tag_name}" then
|
|
266
|
+
set tagFound to true
|
|
267
|
+
exit repeat
|
|
268
|
+
end if
|
|
269
|
+
end repeat
|
|
270
|
+
if not tagFound then
|
|
271
|
+
tell theTodo to make new tag with properties {{name:"{tag_name}"}}
|
|
272
|
+
end if
|
|
273
|
+
''')
|
|
274
|
+
|
|
275
|
+
# Handle checklist items - simplified approach
|
|
276
|
+
if checklist_items is not None:
|
|
277
|
+
# Convert string to list if needed
|
|
278
|
+
if isinstance(checklist_items, str):
|
|
279
|
+
checklist_items = checklist_items.split('\n')
|
|
280
|
+
|
|
281
|
+
if checklist_items:
|
|
282
|
+
# For simplicity, we'll use JSON to pass checklist items
|
|
283
|
+
import json
|
|
284
|
+
items_json = json.dumps([item for item in checklist_items])
|
|
285
|
+
script_parts.append(f'''
|
|
286
|
+
-- Clear and set checklist items
|
|
287
|
+
set oldItems to check list items of theTodo
|
|
288
|
+
repeat with i from (count of oldItems) to 1 by -1
|
|
289
|
+
delete item i of oldItems
|
|
290
|
+
end repeat
|
|
291
|
+
|
|
292
|
+
set itemList to {items_json}
|
|
293
|
+
repeat with i from 1 to (count of itemList)
|
|
294
|
+
set itemText to item i of itemList
|
|
295
|
+
tell theTodo
|
|
296
|
+
set newItem to make new check list item
|
|
297
|
+
set name of newItem to itemText
|
|
298
|
+
end tell
|
|
299
|
+
end repeat
|
|
300
|
+
''')
|
|
301
|
+
|
|
302
|
+
# Handle completion status - use completion date approach
|
|
303
|
+
if completed is not None:
|
|
304
|
+
if completed:
|
|
305
|
+
script_parts.append(' set status of theTodo to completed')
|
|
306
|
+
else:
|
|
307
|
+
script_parts.append(' set status of theTodo to open')
|
|
308
|
+
|
|
309
|
+
# Handle canceled status
|
|
310
|
+
if canceled is not None:
|
|
311
|
+
if canceled:
|
|
312
|
+
script_parts.append(' set status of theTodo to canceled')
|
|
313
|
+
else:
|
|
314
|
+
script_parts.append(' set status of theTodo to open')
|
|
315
|
+
|
|
316
|
+
# Return true on success
|
|
317
|
+
script_parts.append(' return true')
|
|
318
|
+
script_parts.append('on error errMsg')
|
|
319
|
+
script_parts.append(' log "Error updating todo: " & errMsg')
|
|
320
|
+
script_parts.append(' return false')
|
|
321
|
+
script_parts.append('end try')
|
|
322
|
+
script_parts.append('end tell')
|
|
323
|
+
|
|
324
|
+
# Execute the script
|
|
325
|
+
script = '\n'.join(script_parts)
|
|
326
|
+
logger.info(f"Executing AppleScript for update_todo_direct: \n{script}")
|
|
327
|
+
|
|
328
|
+
result = run_applescript(script)
|
|
329
|
+
|
|
330
|
+
if result == "true":
|
|
331
|
+
logger.info(f"Successfully updated todo with ID: {id}")
|
|
332
|
+
return True
|
|
333
|
+
else:
|
|
334
|
+
logger.error(f"AppleScript update_todo_direct failed: {result}")
|
|
335
|
+
return False
|
things_mcp/cache.py
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Caching module for Things MCP server.
|
|
4
|
+
Provides intelligent caching for frequently accessed data to improve performance.
|
|
5
|
+
"""
|
|
6
|
+
import time
|
|
7
|
+
import json
|
|
8
|
+
import hashlib
|
|
9
|
+
import logging
|
|
10
|
+
from typing import Any, Dict, Optional, Callable, Tuple
|
|
11
|
+
from functools import wraps
|
|
12
|
+
from threading import Lock
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
class ThingsCache:
|
|
17
|
+
"""
|
|
18
|
+
Thread-safe cache for Things data with TTL support.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
def __init__(self, default_ttl: int = 300): # 5 minutes default
|
|
22
|
+
"""
|
|
23
|
+
Initialize the cache.
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
default_ttl: Default time-to-live for cache entries in seconds
|
|
27
|
+
"""
|
|
28
|
+
self.cache: Dict[str, Tuple[Any, float]] = {}
|
|
29
|
+
self.default_ttl = default_ttl
|
|
30
|
+
self.lock = Lock()
|
|
31
|
+
self.hit_count = 0
|
|
32
|
+
self.miss_count = 0
|
|
33
|
+
|
|
34
|
+
def _make_key(self, operation: str, **kwargs) -> str:
|
|
35
|
+
"""Generate a cache key from operation and parameters."""
|
|
36
|
+
# Sort kwargs to ensure consistent keys
|
|
37
|
+
sorted_params = json.dumps(kwargs, sort_keys=True)
|
|
38
|
+
key_string = f"{operation}:{sorted_params}"
|
|
39
|
+
# Use hash for shorter keys
|
|
40
|
+
return hashlib.md5(key_string.encode()).hexdigest()
|
|
41
|
+
|
|
42
|
+
def get(self, operation: str, **kwargs) -> Optional[Any]:
|
|
43
|
+
"""
|
|
44
|
+
Get a value from cache if it exists and hasn't expired.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
operation: The operation name
|
|
48
|
+
**kwargs: Parameters for the operation
|
|
49
|
+
|
|
50
|
+
Returns:
|
|
51
|
+
Cached value if available and valid, None otherwise
|
|
52
|
+
"""
|
|
53
|
+
key = self._make_key(operation, **kwargs)
|
|
54
|
+
|
|
55
|
+
with self.lock:
|
|
56
|
+
if key in self.cache:
|
|
57
|
+
value, expiry_time = self.cache[key]
|
|
58
|
+
if time.time() < expiry_time:
|
|
59
|
+
self.hit_count += 1
|
|
60
|
+
logger.debug(f"Cache hit for {operation}: {key}")
|
|
61
|
+
return value
|
|
62
|
+
else:
|
|
63
|
+
# Expired, remove it
|
|
64
|
+
del self.cache[key]
|
|
65
|
+
logger.debug(f"Cache expired for {operation}: {key}")
|
|
66
|
+
|
|
67
|
+
self.miss_count += 1
|
|
68
|
+
return None
|
|
69
|
+
|
|
70
|
+
def set(self, operation: str, value: Any, ttl: Optional[int] = None, **kwargs) -> None:
|
|
71
|
+
"""
|
|
72
|
+
Set a value in the cache.
|
|
73
|
+
|
|
74
|
+
Args:
|
|
75
|
+
operation: The operation name
|
|
76
|
+
value: The value to cache
|
|
77
|
+
ttl: Time-to-live in seconds (uses default if not specified)
|
|
78
|
+
**kwargs: Parameters for the operation
|
|
79
|
+
"""
|
|
80
|
+
key = self._make_key(operation, **kwargs)
|
|
81
|
+
ttl = ttl if ttl is not None else self.default_ttl
|
|
82
|
+
expiry_time = time.time() + ttl
|
|
83
|
+
|
|
84
|
+
with self.lock:
|
|
85
|
+
self.cache[key] = (value, expiry_time)
|
|
86
|
+
logger.debug(f"Cache set for {operation}: {key}, TTL: {ttl}s")
|
|
87
|
+
|
|
88
|
+
def invalidate(self, operation: Optional[str] = None, **kwargs) -> None:
|
|
89
|
+
"""
|
|
90
|
+
Invalidate cache entries.
|
|
91
|
+
|
|
92
|
+
Args:
|
|
93
|
+
operation: If specified, only invalidate entries for this operation
|
|
94
|
+
**kwargs: If specified with operation, invalidate specific entry
|
|
95
|
+
"""
|
|
96
|
+
with self.lock:
|
|
97
|
+
if operation and kwargs:
|
|
98
|
+
# Invalidate specific entry
|
|
99
|
+
key = self._make_key(operation, **kwargs)
|
|
100
|
+
if key in self.cache:
|
|
101
|
+
del self.cache[key]
|
|
102
|
+
logger.debug(f"Invalidated specific cache entry: {key}")
|
|
103
|
+
elif operation:
|
|
104
|
+
# Invalidate all entries for an operation
|
|
105
|
+
keys_to_remove = [k for k in self.cache.keys()
|
|
106
|
+
if k.startswith(hashlib.md5(f"{operation}:".encode()).hexdigest()[:8])]
|
|
107
|
+
for key in keys_to_remove:
|
|
108
|
+
del self.cache[key]
|
|
109
|
+
logger.debug(f"Invalidated {len(keys_to_remove)} cache entries for operation: {operation}")
|
|
110
|
+
else:
|
|
111
|
+
# Clear entire cache
|
|
112
|
+
self.cache.clear()
|
|
113
|
+
logger.info("Cleared entire cache")
|
|
114
|
+
|
|
115
|
+
def cleanup_expired(self) -> int:
|
|
116
|
+
"""Remove expired entries from cache. Returns number of entries removed."""
|
|
117
|
+
current_time = time.time()
|
|
118
|
+
removed_count = 0
|
|
119
|
+
|
|
120
|
+
with self.lock:
|
|
121
|
+
keys_to_remove = []
|
|
122
|
+
for key, (_, expiry_time) in self.cache.items():
|
|
123
|
+
if current_time >= expiry_time:
|
|
124
|
+
keys_to_remove.append(key)
|
|
125
|
+
|
|
126
|
+
for key in keys_to_remove:
|
|
127
|
+
del self.cache[key]
|
|
128
|
+
removed_count += 1
|
|
129
|
+
|
|
130
|
+
if removed_count > 0:
|
|
131
|
+
logger.debug(f"Cleaned up {removed_count} expired cache entries")
|
|
132
|
+
|
|
133
|
+
return removed_count
|
|
134
|
+
|
|
135
|
+
def get_stats(self) -> Dict[str, Any]:
|
|
136
|
+
"""Get cache statistics."""
|
|
137
|
+
with self.lock:
|
|
138
|
+
total_requests = self.hit_count + self.miss_count
|
|
139
|
+
hit_rate = (self.hit_count / total_requests * 100) if total_requests > 0 else 0
|
|
140
|
+
|
|
141
|
+
return {
|
|
142
|
+
"entries": len(self.cache),
|
|
143
|
+
"hits": self.hit_count,
|
|
144
|
+
"misses": self.miss_count,
|
|
145
|
+
"hit_rate": f"{hit_rate:.1f}%",
|
|
146
|
+
"total_requests": total_requests
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
# Global cache instance
|
|
150
|
+
_cache = ThingsCache()
|
|
151
|
+
|
|
152
|
+
def cached(ttl: Optional[int] = None, invalidate_on: Optional[list] = None):
|
|
153
|
+
"""
|
|
154
|
+
Decorator for caching function results.
|
|
155
|
+
|
|
156
|
+
Args:
|
|
157
|
+
ttl: Time-to-live for cached results in seconds
|
|
158
|
+
invalidate_on: List of operation names that should invalidate this cache
|
|
159
|
+
|
|
160
|
+
Example:
|
|
161
|
+
@cached(ttl=60)
|
|
162
|
+
def get_projects():
|
|
163
|
+
return things.projects()
|
|
164
|
+
"""
|
|
165
|
+
def decorator(func: Callable) -> Callable:
|
|
166
|
+
@wraps(func)
|
|
167
|
+
def wrapper(*args, **kwargs):
|
|
168
|
+
# Create operation name from function
|
|
169
|
+
operation = func.__name__
|
|
170
|
+
|
|
171
|
+
# Check cache first
|
|
172
|
+
cached_value = _cache.get(operation, **kwargs)
|
|
173
|
+
if cached_value is not None:
|
|
174
|
+
return cached_value
|
|
175
|
+
|
|
176
|
+
# Call the actual function
|
|
177
|
+
result = func(*args, **kwargs)
|
|
178
|
+
|
|
179
|
+
# Cache the result
|
|
180
|
+
_cache.set(operation, result, ttl=ttl, **kwargs)
|
|
181
|
+
|
|
182
|
+
return result
|
|
183
|
+
|
|
184
|
+
# Store invalidation info
|
|
185
|
+
if invalidate_on:
|
|
186
|
+
wrapper._invalidate_on = invalidate_on
|
|
187
|
+
|
|
188
|
+
return wrapper
|
|
189
|
+
return decorator
|
|
190
|
+
|
|
191
|
+
def invalidate_caches_for(operations: list) -> None:
|
|
192
|
+
"""
|
|
193
|
+
Invalidate caches for specific operations.
|
|
194
|
+
|
|
195
|
+
This is useful when data is modified and related caches need to be cleared.
|
|
196
|
+
|
|
197
|
+
Args:
|
|
198
|
+
operations: List of operation names to invalidate
|
|
199
|
+
"""
|
|
200
|
+
for operation in operations:
|
|
201
|
+
_cache.invalidate(operation)
|
|
202
|
+
|
|
203
|
+
def get_cache_stats() -> Dict[str, Any]:
|
|
204
|
+
"""Get cache statistics."""
|
|
205
|
+
return _cache.get_stats()
|
|
206
|
+
|
|
207
|
+
def clear_cache() -> None:
|
|
208
|
+
"""Clear all cached data."""
|
|
209
|
+
_cache.invalidate()
|
|
210
|
+
|
|
211
|
+
# TTL configurations for different types of data
|
|
212
|
+
CACHE_TTL = {
|
|
213
|
+
"inbox": 30, # 30 seconds - changes frequently
|
|
214
|
+
"today": 30, # 30 seconds - changes frequently
|
|
215
|
+
"upcoming": 60, # 1 minute
|
|
216
|
+
"anytime": 300, # 5 minutes
|
|
217
|
+
"someday": 300, # 5 minutes
|
|
218
|
+
"projects": 300, # 5 minutes
|
|
219
|
+
"areas": 600, # 10 minutes - rarely changes
|
|
220
|
+
"tags": 600, # 10 minutes - rarely changes
|
|
221
|
+
"logbook": 300, # 5 minutes
|
|
222
|
+
"trash": 300, # 5 minutes
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
# Auto-cleanup task
|
|
226
|
+
def start_cache_cleanup_task(interval: int = 300):
|
|
227
|
+
"""Start a background task to clean up expired cache entries."""
|
|
228
|
+
import threading
|
|
229
|
+
|
|
230
|
+
def cleanup_task():
|
|
231
|
+
while True:
|
|
232
|
+
time.sleep(interval)
|
|
233
|
+
_cache.cleanup_expired()
|
|
234
|
+
|
|
235
|
+
thread = threading.Thread(target=cleanup_task, daemon=True)
|
|
236
|
+
thread.start()
|
|
237
|
+
logger.info(f"Started cache cleanup task with {interval}s interval")
|
|
238
|
+
|
|
239
|
+
# Start cleanup task when module is imported
|
|
240
|
+
start_cache_cleanup_task()
|
things_mcp/config.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Configuration module for Things MCP.
|
|
3
|
+
Stores settings and user-specific configuration values.
|
|
4
|
+
"""
|
|
5
|
+
import os
|
|
6
|
+
import json
|
|
7
|
+
import logging
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
# Default configuration values
|
|
13
|
+
DEFAULT_CONFIG = {
|
|
14
|
+
"things_auth_token": "", # Empty by default, must be set by user
|
|
15
|
+
"retry_attempts": 3,
|
|
16
|
+
"retry_delay": 1.0
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
# Path to the configuration file
|
|
20
|
+
CONFIG_DIR = Path.home() / ".things-mcp"
|
|
21
|
+
CONFIG_FILE = CONFIG_DIR / "config.json"
|
|
22
|
+
|
|
23
|
+
# Global configuration dictionary
|
|
24
|
+
_config = None
|
|
25
|
+
|
|
26
|
+
def load_config():
|
|
27
|
+
"""Load configuration from file, create with defaults if it doesn't exist."""
|
|
28
|
+
global _config
|
|
29
|
+
|
|
30
|
+
# Create config directory if it doesn't exist
|
|
31
|
+
if not CONFIG_DIR.exists():
|
|
32
|
+
try:
|
|
33
|
+
CONFIG_DIR.mkdir(parents=True)
|
|
34
|
+
logger.info(f"Created configuration directory: {CONFIG_DIR}")
|
|
35
|
+
except Exception as e:
|
|
36
|
+
logger.error(f"Failed to create config directory: {e}")
|
|
37
|
+
return DEFAULT_CONFIG
|
|
38
|
+
|
|
39
|
+
# Check if config file exists
|
|
40
|
+
if not CONFIG_FILE.exists():
|
|
41
|
+
# Create default config file
|
|
42
|
+
try:
|
|
43
|
+
with open(CONFIG_FILE, 'w') as f:
|
|
44
|
+
json.dump(DEFAULT_CONFIG, f, indent=2)
|
|
45
|
+
logger.info(f"Created default configuration file: {CONFIG_FILE}")
|
|
46
|
+
_config = DEFAULT_CONFIG.copy()
|
|
47
|
+
except Exception as e:
|
|
48
|
+
logger.error(f"Failed to create config file: {e}")
|
|
49
|
+
_config = DEFAULT_CONFIG.copy()
|
|
50
|
+
else:
|
|
51
|
+
# Load existing config
|
|
52
|
+
try:
|
|
53
|
+
with open(CONFIG_FILE, 'r') as f:
|
|
54
|
+
loaded_config = json.load(f)
|
|
55
|
+
|
|
56
|
+
# Ensure all required keys are present
|
|
57
|
+
_config = DEFAULT_CONFIG.copy()
|
|
58
|
+
_config.update(loaded_config)
|
|
59
|
+
|
|
60
|
+
logger.info(f"Loaded configuration from: {CONFIG_FILE}")
|
|
61
|
+
except Exception as e:
|
|
62
|
+
logger.error(f"Failed to load config file: {e}")
|
|
63
|
+
_config = DEFAULT_CONFIG.copy()
|
|
64
|
+
|
|
65
|
+
return _config
|
|
66
|
+
|
|
67
|
+
def save_config():
|
|
68
|
+
"""Save current configuration to file."""
|
|
69
|
+
if _config is None:
|
|
70
|
+
logger.error("Cannot save config: No configuration loaded")
|
|
71
|
+
return False
|
|
72
|
+
|
|
73
|
+
try:
|
|
74
|
+
with open(CONFIG_FILE, 'w') as f:
|
|
75
|
+
json.dump(_config, f, indent=2)
|
|
76
|
+
logger.info(f"Saved configuration to: {CONFIG_FILE}")
|
|
77
|
+
return True
|
|
78
|
+
except Exception as e:
|
|
79
|
+
logger.error(f"Failed to save config: {e}")
|
|
80
|
+
return False
|
|
81
|
+
|
|
82
|
+
def get_config():
|
|
83
|
+
"""Get the current configuration, loading it if necessary."""
|
|
84
|
+
global _config
|
|
85
|
+
if _config is None:
|
|
86
|
+
_config = load_config()
|
|
87
|
+
return _config
|
|
88
|
+
|
|
89
|
+
def set_config_value(key, value):
|
|
90
|
+
"""Set a configuration value and save to file."""
|
|
91
|
+
config = get_config()
|
|
92
|
+
config[key] = value
|
|
93
|
+
return save_config()
|
|
94
|
+
|
|
95
|
+
def get_config_value(key, default=None):
|
|
96
|
+
"""Get a configuration value, return default if not found."""
|
|
97
|
+
config = get_config()
|
|
98
|
+
return config.get(key, default)
|
|
99
|
+
|
|
100
|
+
def get_things_auth_token():
|
|
101
|
+
"""Get the Things authentication token.
|
|
102
|
+
|
|
103
|
+
First checks environment variable THINGS_AUTH_TOKEN,
|
|
104
|
+
then falls back to config file.
|
|
105
|
+
"""
|
|
106
|
+
# Check environment variable first
|
|
107
|
+
token = os.environ.get('THINGS_AUTH_TOKEN')
|
|
108
|
+
if token:
|
|
109
|
+
logger.debug("Using Things auth token from environment variable")
|
|
110
|
+
return token
|
|
111
|
+
|
|
112
|
+
# Fall back to config file
|
|
113
|
+
return get_config_value("things_auth_token", "")
|
|
114
|
+
|
|
115
|
+
def set_things_auth_token(token):
|
|
116
|
+
"""Set the Things authentication token."""
|
|
117
|
+
return set_config_value("things_auth_token", token)
|
|
118
|
+
|
|
119
|
+
# Initialize configuration on module import
|
|
120
|
+
load_config()
|