things-mcp 0.7.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/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Things MCP - Model Context Protocol server for Things 3 task management app."""
2
+
3
+ from .server import mcp
4
+
5
+ __all__ = ["mcp"]
things_mcp/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Entry point for running things-mcp as a module or via uvx."""
2
+
3
+ from .server import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
@@ -0,0 +1,280 @@
1
+ import logging
2
+ import things
3
+ from datetime import datetime
4
+
5
+ logger = logging.getLogger(__name__)
6
+
7
+ def _calculate_age(date_str: str) -> str:
8
+ """Helper function to calculate human-readable age from a date string.
9
+
10
+ Args:
11
+ date_str: ISO format date string
12
+
13
+ Returns:
14
+ Human-readable age string (e.g., "3 days ago", "2 weeks ago")
15
+
16
+ Raises:
17
+ ValueError: If date string cannot be parsed
18
+ TypeError: If date_str is not a string
19
+ """
20
+ date_obj = datetime.fromisoformat(str(date_str))
21
+ age = datetime.now() - date_obj
22
+ days = age.days
23
+
24
+ if days == 0:
25
+ return "today"
26
+ elif days == 1:
27
+ return "1 day ago"
28
+ elif days < 7:
29
+ return f"{days} days ago"
30
+ elif days < 30:
31
+ weeks = days // 7
32
+ return f"{weeks} week{'s' if weeks > 1 else ''} ago"
33
+ elif days < 365:
34
+ months = days // 30
35
+ return f"{months} month{'s' if months > 1 else ''} ago"
36
+ else:
37
+ years = days // 365
38
+ return f"{years} year{'s' if years > 1 else ''} ago"
39
+
40
+ def format_todo(todo: dict) -> str:
41
+ """Helper function to format a single todo into a readable string."""
42
+ logger.debug(f"Formatting todo: {todo}")
43
+ todo_text = f"Title: {todo['title']}"
44
+
45
+ # Add UUID for reference
46
+ todo_text += f"\nUUID: {todo['uuid']}"
47
+
48
+ # Add type
49
+ todo_text += f"\nType: {todo['type']}"
50
+
51
+ # Add status if present
52
+ if todo.get('status'):
53
+ todo_text += f"\nStatus: {todo['status']}"
54
+
55
+ # Add start/list location
56
+ if todo.get('start'):
57
+ todo_text += f"\nList: {todo['start']}"
58
+
59
+ # Add dates
60
+ if todo.get('start_date'):
61
+ todo_text += f"\nStart Date: {todo['start_date']}"
62
+ if todo.get('deadline'):
63
+ todo_text += f"\nDeadline: {todo['deadline']}"
64
+ if todo.get('stop_date'): # Completion date
65
+ todo_text += f"\nCompleted: {todo['stop_date']}"
66
+
67
+ # Add creation and modification dates
68
+ if todo.get('created'):
69
+ todo_text += f"\nCreated: {todo['created']}"
70
+ # Calculate age since creation
71
+ try:
72
+ age_text = _calculate_age(todo['created'])
73
+ todo_text += f"\nAge: {age_text}"
74
+ except (ValueError, TypeError):
75
+ pass
76
+
77
+ if todo.get('modified'):
78
+ todo_text += f"\nModified: {todo['modified']}"
79
+ # Calculate time since last modification
80
+ try:
81
+ modified_age = _calculate_age(todo['modified'])
82
+ todo_text += f"\nLast modified: {modified_age}"
83
+ except (ValueError, TypeError):
84
+ pass
85
+
86
+ # Add notes if present
87
+ if todo.get('notes'):
88
+ todo_text += f"\nNotes: {todo['notes']}"
89
+
90
+ # Add project info if present
91
+ if todo.get('project'):
92
+ try:
93
+ project = things.get(todo['project'])
94
+ if project:
95
+ todo_text += f"\nProject: {project['title']}"
96
+ except Exception:
97
+ pass
98
+
99
+ # Add heading info if present
100
+ if todo.get('heading'):
101
+ try:
102
+ heading = things.get(todo['heading'])
103
+ if heading:
104
+ todo_text += f"\nHeading: {heading['title']}"
105
+ except Exception:
106
+ pass
107
+
108
+ # Add area info if present
109
+ if todo.get('area'):
110
+ try:
111
+ area = things.get(todo['area'])
112
+ if area:
113
+ todo_text += f"\nArea: {area['title']}"
114
+ except Exception:
115
+ pass
116
+
117
+ # Add tags if present
118
+ if todo.get('tags'):
119
+ todo_text += f"\nTags: {', '.join(todo['tags'])}"
120
+
121
+ # Add checklist if present and contains items
122
+ if isinstance(todo.get('checklist'), list):
123
+ todo_text += "\nChecklist:"
124
+ for item in todo['checklist']:
125
+ checkbox = "✓" if item.get('status') == 'completed' else "☐"
126
+ todo_text += f"\n {checkbox} {item['title']}"
127
+
128
+ return todo_text
129
+
130
+ def format_project(project: dict, include_items: bool = False) -> str:
131
+ """Helper function to format a single project."""
132
+ project_text = f"Title: {project['title']}\nUUID: {project['uuid']}"
133
+
134
+ if project.get('area'):
135
+ try:
136
+ area = things.get(project['area'])
137
+ if area:
138
+ project_text += f"\nArea: {area['title']}"
139
+ except Exception:
140
+ pass
141
+
142
+ if project.get('notes'):
143
+ project_text += f"\nNotes: {project['notes']}"
144
+
145
+ # Add creation and modification dates
146
+ if project.get('created'):
147
+ project_text += f"\nCreated: {project['created']}"
148
+ # Calculate age since creation
149
+ try:
150
+ age_text = _calculate_age(project['created'])
151
+ project_text += f"\nAge: {age_text}"
152
+ except (ValueError, TypeError):
153
+ pass
154
+
155
+ if project.get('modified'):
156
+ project_text += f"\nModified: {project['modified']}"
157
+ # Calculate time since last modification
158
+ try:
159
+ modified_age = _calculate_age(project['modified'])
160
+ project_text += f"\nLast modified: {modified_age}"
161
+ except (ValueError, TypeError):
162
+ pass
163
+
164
+ # Always show headings for projects
165
+ headings = things.tasks(type='heading', project=project['uuid'])
166
+ if headings:
167
+ project_text += "\n\nHeadings:"
168
+ for heading in headings:
169
+ project_text += f"\n- {heading['title']}"
170
+
171
+ if include_items:
172
+ todos = things.todos(project=project['uuid'])
173
+ if todos:
174
+ project_text += "\n\nTasks:"
175
+ for todo in todos:
176
+ project_text += f"\n- {todo['title']}"
177
+
178
+ return project_text
179
+
180
+ def format_area(area: dict, include_items: bool = False) -> str:
181
+ """Helper function to format a single area."""
182
+ area_text = f"Title: {area['title']}\nUUID: {area['uuid']}"
183
+
184
+ if area.get('notes'):
185
+ area_text += f"\nNotes: {area['notes']}"
186
+
187
+ # Add creation and modification dates
188
+ if area.get('created'):
189
+ area_text += f"\nCreated: {area['created']}"
190
+ try:
191
+ age_text = _calculate_age(area['created'])
192
+ area_text += f"\nAge: {age_text}"
193
+ except (ValueError, TypeError):
194
+ pass
195
+
196
+ if area.get('modified'):
197
+ area_text += f"\nModified: {area['modified']}"
198
+ try:
199
+ modified_age = _calculate_age(area['modified'])
200
+ area_text += f"\nLast modified: {modified_age}"
201
+ except (ValueError, TypeError):
202
+ pass
203
+
204
+ if include_items:
205
+ projects = things.projects(area=area['uuid'])
206
+ if projects:
207
+ area_text += "\n\nProjects:"
208
+ for project in projects:
209
+ area_text += f"\n- {project['title']}"
210
+
211
+ todos = things.todos(area=area['uuid'])
212
+ if todos:
213
+ area_text += "\n\nTasks:"
214
+ for todo in todos:
215
+ area_text += f"\n- {todo['title']}"
216
+
217
+ return area_text
218
+
219
+ def format_tag(tag: dict, include_items: bool = False) -> str:
220
+ """Helper function to format a single tag."""
221
+ tag_text = f"Title: {tag['title']}\nUUID: {tag['uuid']}"
222
+
223
+ if tag.get('shortcut'):
224
+ tag_text += f"\nShortcut: {tag['shortcut']}"
225
+
226
+ if include_items:
227
+ todos = things.todos(tag=tag['title'])
228
+ if todos:
229
+ tag_text += "\n\nTagged Items:"
230
+ for todo in todos:
231
+ tag_text += f"\n- {todo['title']}"
232
+
233
+ return tag_text
234
+
235
+ def format_heading(heading: dict, include_items: bool = False) -> str:
236
+ """Helper function to format a single heading."""
237
+ heading_text = f"Title: {heading['title']}\nUUID: {heading['uuid']}"
238
+ heading_text += f"\nType: heading"
239
+
240
+ # Add project info if present
241
+ if heading.get('project'):
242
+ if heading.get('project_title'):
243
+ heading_text += f"\nProject: {heading['project_title']}"
244
+ else:
245
+ try:
246
+ project = things.get(heading['project'])
247
+ if project:
248
+ heading_text += f"\nProject: {project['title']}"
249
+ except Exception:
250
+ pass
251
+
252
+ # Add dates
253
+ if heading.get('created'):
254
+ heading_text += f"\nCreated: {heading['created']}"
255
+ try:
256
+ age_text = _calculate_age(heading['created'])
257
+ heading_text += f"\nAge: {age_text}"
258
+ except (ValueError, TypeError):
259
+ pass
260
+ if heading.get('modified'):
261
+ heading_text += f"\nModified: {heading['modified']}"
262
+ try:
263
+ modified_age = _calculate_age(heading['modified'])
264
+ heading_text += f"\nLast modified: {modified_age}"
265
+ except (ValueError, TypeError):
266
+ pass
267
+
268
+ # Add notes if present
269
+ if heading.get('notes'):
270
+ heading_text += f"\nNotes: {heading['notes']}"
271
+
272
+ if include_items:
273
+ # Get todos under this heading
274
+ todos = things.todos(heading=heading['uuid'])
275
+ if todos:
276
+ heading_text += "\n\nTasks under heading:"
277
+ for todo in todos:
278
+ heading_text += f"\n- {todo['title']}"
279
+
280
+ return heading_text