iflow-mcp_democratize-technology-chronos-mcp 2.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.
- chronos_mcp/__init__.py +5 -0
- chronos_mcp/__main__.py +9 -0
- chronos_mcp/accounts.py +410 -0
- chronos_mcp/bulk.py +946 -0
- chronos_mcp/caldav_utils.py +149 -0
- chronos_mcp/calendars.py +204 -0
- chronos_mcp/config.py +187 -0
- chronos_mcp/credentials.py +190 -0
- chronos_mcp/events.py +515 -0
- chronos_mcp/exceptions.py +477 -0
- chronos_mcp/journals.py +477 -0
- chronos_mcp/logging_config.py +23 -0
- chronos_mcp/models.py +202 -0
- chronos_mcp/py.typed +0 -0
- chronos_mcp/rrule.py +259 -0
- chronos_mcp/search.py +315 -0
- chronos_mcp/server.py +121 -0
- chronos_mcp/tasks.py +518 -0
- chronos_mcp/tools/__init__.py +29 -0
- chronos_mcp/tools/accounts.py +151 -0
- chronos_mcp/tools/base.py +59 -0
- chronos_mcp/tools/bulk.py +557 -0
- chronos_mcp/tools/calendars.py +142 -0
- chronos_mcp/tools/events.py +698 -0
- chronos_mcp/tools/journals.py +310 -0
- chronos_mcp/tools/tasks.py +414 -0
- chronos_mcp/utils.py +163 -0
- chronos_mcp/validation.py +636 -0
- iflow_mcp_democratize_technology_chronos_mcp-2.0.0.dist-info/METADATA +299 -0
- iflow_mcp_democratize_technology_chronos_mcp-2.0.0.dist-info/RECORD +68 -0
- iflow_mcp_democratize_technology_chronos_mcp-2.0.0.dist-info/WHEEL +5 -0
- iflow_mcp_democratize_technology_chronos_mcp-2.0.0.dist-info/entry_points.txt +2 -0
- iflow_mcp_democratize_technology_chronos_mcp-2.0.0.dist-info/licenses/LICENSE +21 -0
- iflow_mcp_democratize_technology_chronos_mcp-2.0.0.dist-info/top_level.txt +2 -0
- tests/__init__.py +0 -0
- tests/conftest.py +91 -0
- tests/unit/__init__.py +0 -0
- tests/unit/test_accounts.py +380 -0
- tests/unit/test_accounts_ssrf.py +134 -0
- tests/unit/test_base.py +135 -0
- tests/unit/test_bulk.py +380 -0
- tests/unit/test_bulk_create.py +408 -0
- tests/unit/test_bulk_delete.py +341 -0
- tests/unit/test_bulk_resource_limits.py +74 -0
- tests/unit/test_caldav_utils.py +300 -0
- tests/unit/test_calendars.py +286 -0
- tests/unit/test_config.py +111 -0
- tests/unit/test_config_validation.py +128 -0
- tests/unit/test_credentials_security.py +189 -0
- tests/unit/test_cryptography_security.py +178 -0
- tests/unit/test_events.py +536 -0
- tests/unit/test_exceptions.py +58 -0
- tests/unit/test_journals.py +1097 -0
- tests/unit/test_models.py +95 -0
- tests/unit/test_race_conditions.py +202 -0
- tests/unit/test_recurring_events.py +156 -0
- tests/unit/test_rrule.py +217 -0
- tests/unit/test_search.py +372 -0
- tests/unit/test_search_advanced.py +333 -0
- tests/unit/test_server_input_validation.py +219 -0
- tests/unit/test_ssrf_protection.py +505 -0
- tests/unit/test_tasks.py +918 -0
- tests/unit/test_thread_safety.py +301 -0
- tests/unit/test_tools_journals.py +617 -0
- tests/unit/test_tools_tasks.py +968 -0
- tests/unit/test_url_validation_security.py +234 -0
- tests/unit/test_utils.py +180 -0
- tests/unit/test_validation.py +983 -0
chronos_mcp/tasks.py
ADDED
|
@@ -0,0 +1,518 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Task operations for Chronos MCP
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import uuid
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
from typing import List, Optional
|
|
8
|
+
|
|
9
|
+
import caldav
|
|
10
|
+
from caldav import Event as CalDAVEvent
|
|
11
|
+
from icalendar import Calendar as iCalendar
|
|
12
|
+
from icalendar import Todo as iTodo
|
|
13
|
+
|
|
14
|
+
from .caldav_utils import get_item_with_fallback
|
|
15
|
+
from .calendars import CalendarManager
|
|
16
|
+
from .exceptions import (
|
|
17
|
+
CalendarNotFoundError,
|
|
18
|
+
ChronosError,
|
|
19
|
+
EventCreationError,
|
|
20
|
+
EventDeletionError,
|
|
21
|
+
TaskNotFoundError,
|
|
22
|
+
)
|
|
23
|
+
from .logging_config import setup_logging
|
|
24
|
+
from .models import Task, TaskStatus
|
|
25
|
+
from .utils import ical_to_datetime
|
|
26
|
+
|
|
27
|
+
logger = setup_logging()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class TaskManager:
|
|
31
|
+
"""Manage calendar tasks (VTODO)"""
|
|
32
|
+
|
|
33
|
+
def __init__(self, calendar_manager: CalendarManager):
|
|
34
|
+
self.calendars = calendar_manager
|
|
35
|
+
|
|
36
|
+
def _get_default_account(self) -> Optional[str]:
|
|
37
|
+
try:
|
|
38
|
+
return self.calendars.accounts.config.config.default_account
|
|
39
|
+
except Exception:
|
|
40
|
+
return None
|
|
41
|
+
|
|
42
|
+
def create_task(
|
|
43
|
+
self,
|
|
44
|
+
calendar_uid: str,
|
|
45
|
+
summary: str,
|
|
46
|
+
description: Optional[str] = None,
|
|
47
|
+
due: Optional[datetime] = None,
|
|
48
|
+
priority: Optional[int] = None,
|
|
49
|
+
status: TaskStatus = TaskStatus.NEEDS_ACTION,
|
|
50
|
+
related_to: Optional[List[str]] = None,
|
|
51
|
+
account_alias: Optional[str] = None,
|
|
52
|
+
request_id: Optional[str] = None,
|
|
53
|
+
) -> Optional[Task]:
|
|
54
|
+
"""Create a new task - raises exceptions on failure"""
|
|
55
|
+
request_id = request_id or str(uuid.uuid4())
|
|
56
|
+
|
|
57
|
+
calendar = self.calendars.get_calendar(
|
|
58
|
+
calendar_uid, account_alias, request_id=request_id
|
|
59
|
+
)
|
|
60
|
+
if not calendar:
|
|
61
|
+
raise CalendarNotFoundError(
|
|
62
|
+
calendar_uid, account_alias, request_id=request_id
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
try:
|
|
66
|
+
cal = iCalendar()
|
|
67
|
+
task = iTodo()
|
|
68
|
+
|
|
69
|
+
# Generate UID if not provided
|
|
70
|
+
task_uid = str(uuid.uuid4())
|
|
71
|
+
|
|
72
|
+
task.add("uid", task_uid)
|
|
73
|
+
task.add("summary", summary)
|
|
74
|
+
task.add("dtstamp", datetime.now(timezone.utc))
|
|
75
|
+
|
|
76
|
+
if description:
|
|
77
|
+
task.add("description", description)
|
|
78
|
+
if due:
|
|
79
|
+
task.add("due", due)
|
|
80
|
+
if priority is not None and 1 <= priority <= 9:
|
|
81
|
+
task.add("priority", priority)
|
|
82
|
+
task.add("status", status.value)
|
|
83
|
+
task.add("percent-complete", 0)
|
|
84
|
+
|
|
85
|
+
if related_to:
|
|
86
|
+
for related_uid in related_to:
|
|
87
|
+
task.add("related-to", related_uid)
|
|
88
|
+
|
|
89
|
+
cal.add_component(task)
|
|
90
|
+
|
|
91
|
+
# Save to CalDAV server using component-specific method when available
|
|
92
|
+
ical_data = cal.to_ical().decode("utf-8")
|
|
93
|
+
|
|
94
|
+
if hasattr(calendar, "save_todo"):
|
|
95
|
+
logger.debug(
|
|
96
|
+
"Using calendar.save_todo() for optimized task creation",
|
|
97
|
+
extra={"request_id": request_id},
|
|
98
|
+
)
|
|
99
|
+
try:
|
|
100
|
+
caldav_task = calendar.save_todo(ical_data)
|
|
101
|
+
except Exception as e:
|
|
102
|
+
logger.warning(
|
|
103
|
+
f"calendar.save_todo() failed: {e}, falling back to save_event()",
|
|
104
|
+
extra={"request_id": request_id},
|
|
105
|
+
)
|
|
106
|
+
caldav_task = calendar.save_event(ical_data)
|
|
107
|
+
else:
|
|
108
|
+
logger.debug(
|
|
109
|
+
"Server doesn't support calendar.save_todo(), using calendar.save_event()",
|
|
110
|
+
extra={"request_id": request_id},
|
|
111
|
+
)
|
|
112
|
+
caldav_task = calendar.save_event(ical_data)
|
|
113
|
+
|
|
114
|
+
task_model = Task(
|
|
115
|
+
uid=task_uid,
|
|
116
|
+
summary=summary,
|
|
117
|
+
description=description,
|
|
118
|
+
due=due,
|
|
119
|
+
priority=priority,
|
|
120
|
+
status=status,
|
|
121
|
+
percent_complete=0,
|
|
122
|
+
related_to=related_to or [],
|
|
123
|
+
calendar_uid=calendar_uid,
|
|
124
|
+
account_alias=account_alias or self._get_default_account() or "default",
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
return task_model
|
|
128
|
+
|
|
129
|
+
except caldav.lib.error.AuthorizationError as e:
|
|
130
|
+
logger.error(
|
|
131
|
+
f"Authorization error creating task '{summary}': {e}",
|
|
132
|
+
extra={"request_id": request_id},
|
|
133
|
+
)
|
|
134
|
+
raise EventCreationError(
|
|
135
|
+
summary, "Authorization failed", request_id=request_id
|
|
136
|
+
)
|
|
137
|
+
except Exception as e:
|
|
138
|
+
logger.error(
|
|
139
|
+
f"Error creating task '{summary}': {e}",
|
|
140
|
+
extra={"request_id": request_id},
|
|
141
|
+
)
|
|
142
|
+
raise EventCreationError(summary, str(e), request_id=request_id)
|
|
143
|
+
|
|
144
|
+
def get_task(
|
|
145
|
+
self,
|
|
146
|
+
task_uid: str,
|
|
147
|
+
calendar_uid: str,
|
|
148
|
+
account_alias: Optional[str] = None,
|
|
149
|
+
request_id: Optional[str] = None,
|
|
150
|
+
) -> Optional[Task]:
|
|
151
|
+
"""Get a specific task by UID"""
|
|
152
|
+
request_id = request_id or str(uuid.uuid4())
|
|
153
|
+
|
|
154
|
+
calendar = self.calendars.get_calendar(
|
|
155
|
+
calendar_uid, account_alias, request_id=request_id
|
|
156
|
+
)
|
|
157
|
+
if not calendar:
|
|
158
|
+
raise CalendarNotFoundError(
|
|
159
|
+
calendar_uid, account_alias, request_id=request_id
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
try:
|
|
163
|
+
# Use utility function to find task with automatic fallback
|
|
164
|
+
caldav_task = get_item_with_fallback(
|
|
165
|
+
calendar, task_uid, "task", request_id=request_id
|
|
166
|
+
)
|
|
167
|
+
return self._parse_caldav_task(caldav_task, calendar_uid, account_alias)
|
|
168
|
+
except ValueError:
|
|
169
|
+
# get_item_with_fallback raises ValueError when not found
|
|
170
|
+
raise TaskNotFoundError(task_uid, calendar_uid, request_id=request_id)
|
|
171
|
+
|
|
172
|
+
except TaskNotFoundError:
|
|
173
|
+
raise
|
|
174
|
+
except Exception as e:
|
|
175
|
+
logger.error(
|
|
176
|
+
f"Error getting task '{task_uid}': {e}",
|
|
177
|
+
extra={"request_id": request_id},
|
|
178
|
+
)
|
|
179
|
+
raise ChronosError(f"Failed to get task: {str(e)}", request_id=request_id)
|
|
180
|
+
|
|
181
|
+
def list_tasks(
|
|
182
|
+
self,
|
|
183
|
+
calendar_uid: str,
|
|
184
|
+
status_filter: Optional[TaskStatus] = None,
|
|
185
|
+
account_alias: Optional[str] = None,
|
|
186
|
+
request_id: Optional[str] = None,
|
|
187
|
+
) -> List[Task]:
|
|
188
|
+
"""List all tasks in a calendar"""
|
|
189
|
+
request_id = request_id or str(uuid.uuid4())
|
|
190
|
+
|
|
191
|
+
calendar = self.calendars.get_calendar(
|
|
192
|
+
calendar_uid, account_alias, request_id=request_id
|
|
193
|
+
)
|
|
194
|
+
if not calendar:
|
|
195
|
+
raise CalendarNotFoundError(
|
|
196
|
+
calendar_uid, account_alias, request_id=request_id
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
tasks = []
|
|
200
|
+
try:
|
|
201
|
+
# Try component-specific method first for better performance
|
|
202
|
+
if hasattr(calendar, "todos"):
|
|
203
|
+
try:
|
|
204
|
+
logger.debug(
|
|
205
|
+
"Using calendar.todos() for server-side filtering",
|
|
206
|
+
extra={"request_id": request_id},
|
|
207
|
+
)
|
|
208
|
+
todos = calendar.todos()
|
|
209
|
+
|
|
210
|
+
for caldav_todo in todos:
|
|
211
|
+
task_data = self._parse_caldav_task(
|
|
212
|
+
caldav_todo, calendar_uid, account_alias
|
|
213
|
+
)
|
|
214
|
+
if task_data:
|
|
215
|
+
tasks.append(task_data)
|
|
216
|
+
|
|
217
|
+
except Exception as e:
|
|
218
|
+
logger.warning(
|
|
219
|
+
f"calendar.todos() failed: {e}, falling back to calendar.events()",
|
|
220
|
+
extra={"request_id": request_id},
|
|
221
|
+
)
|
|
222
|
+
# Fall through to fallback method
|
|
223
|
+
raise
|
|
224
|
+
else:
|
|
225
|
+
# Fallback method for servers without todos() support
|
|
226
|
+
logger.debug(
|
|
227
|
+
"Server doesn't support calendar.todos(), using calendar.events() with client-side filtering",
|
|
228
|
+
extra={"request_id": request_id},
|
|
229
|
+
)
|
|
230
|
+
events = calendar.events()
|
|
231
|
+
|
|
232
|
+
for caldav_event in events:
|
|
233
|
+
task_data = self._parse_caldav_task(
|
|
234
|
+
caldav_event, calendar_uid, account_alias
|
|
235
|
+
)
|
|
236
|
+
if task_data:
|
|
237
|
+
tasks.append(task_data)
|
|
238
|
+
|
|
239
|
+
except Exception as e:
|
|
240
|
+
# If todos() method failed, try the fallback approach
|
|
241
|
+
if hasattr(calendar, "todos"):
|
|
242
|
+
try:
|
|
243
|
+
logger.info(
|
|
244
|
+
"Retrying with calendar.events() fallback method",
|
|
245
|
+
extra={"request_id": request_id},
|
|
246
|
+
)
|
|
247
|
+
events = calendar.events()
|
|
248
|
+
|
|
249
|
+
for caldav_event in events:
|
|
250
|
+
task_data = self._parse_caldav_task(
|
|
251
|
+
caldav_event, calendar_uid, account_alias
|
|
252
|
+
)
|
|
253
|
+
if task_data:
|
|
254
|
+
tasks.append(task_data)
|
|
255
|
+
except Exception as fallback_error:
|
|
256
|
+
logger.error(
|
|
257
|
+
f"Error listing tasks (both methods failed): {fallback_error}",
|
|
258
|
+
extra={"request_id": request_id},
|
|
259
|
+
)
|
|
260
|
+
else:
|
|
261
|
+
logger.error(
|
|
262
|
+
f"Error listing tasks: {e}", extra={"request_id": request_id}
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
# Filter by status if requested
|
|
266
|
+
if status_filter:
|
|
267
|
+
tasks = [task for task in tasks if task.status == status_filter]
|
|
268
|
+
logger.debug(
|
|
269
|
+
f"Filtered tasks by status {status_filter.value}: {len(tasks)} tasks",
|
|
270
|
+
extra={"request_id": request_id},
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
return tasks
|
|
274
|
+
|
|
275
|
+
def update_task(
|
|
276
|
+
self,
|
|
277
|
+
task_uid: str,
|
|
278
|
+
calendar_uid: str,
|
|
279
|
+
summary: Optional[str] = None,
|
|
280
|
+
description: Optional[str] = None,
|
|
281
|
+
due: Optional[datetime] = None,
|
|
282
|
+
priority: Optional[int] = None,
|
|
283
|
+
status: Optional[TaskStatus] = None,
|
|
284
|
+
percent_complete: Optional[int] = None,
|
|
285
|
+
related_to: Optional[List[str]] = None,
|
|
286
|
+
account_alias: Optional[str] = None,
|
|
287
|
+
request_id: Optional[str] = None,
|
|
288
|
+
) -> Optional[Task]:
|
|
289
|
+
"""Update an existing task - raises exceptions on failure"""
|
|
290
|
+
request_id = request_id or str(uuid.uuid4())
|
|
291
|
+
|
|
292
|
+
calendar = self.calendars.get_calendar(
|
|
293
|
+
calendar_uid, account_alias, request_id=request_id
|
|
294
|
+
)
|
|
295
|
+
if not calendar:
|
|
296
|
+
raise CalendarNotFoundError(
|
|
297
|
+
calendar_uid, account_alias, request_id=request_id
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
try:
|
|
301
|
+
# Use utility function to find task with automatic fallback
|
|
302
|
+
try:
|
|
303
|
+
caldav_task = get_item_with_fallback(
|
|
304
|
+
calendar, task_uid, "task", request_id=request_id
|
|
305
|
+
)
|
|
306
|
+
except ValueError:
|
|
307
|
+
raise TaskNotFoundError(task_uid, calendar_uid, request_id=request_id)
|
|
308
|
+
|
|
309
|
+
# Parse existing task data
|
|
310
|
+
ical = iCalendar.from_ical(caldav_task.data)
|
|
311
|
+
existing_task = None
|
|
312
|
+
|
|
313
|
+
for component in ical.walk():
|
|
314
|
+
if component.name == "VTODO":
|
|
315
|
+
existing_task = component
|
|
316
|
+
break
|
|
317
|
+
|
|
318
|
+
if not existing_task:
|
|
319
|
+
raise EventCreationError(
|
|
320
|
+
f"Task {task_uid}",
|
|
321
|
+
"Could not parse existing task data",
|
|
322
|
+
request_id=request_id,
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
# Update only provided fields
|
|
326
|
+
if summary is not None:
|
|
327
|
+
existing_task["SUMMARY"] = summary
|
|
328
|
+
|
|
329
|
+
if description is not None:
|
|
330
|
+
if description:
|
|
331
|
+
existing_task["DESCRIPTION"] = description
|
|
332
|
+
elif "DESCRIPTION" in existing_task:
|
|
333
|
+
del existing_task["DESCRIPTION"]
|
|
334
|
+
|
|
335
|
+
if due is not None:
|
|
336
|
+
if "DUE" in existing_task:
|
|
337
|
+
del existing_task["DUE"]
|
|
338
|
+
if due:
|
|
339
|
+
existing_task.add("DUE", due)
|
|
340
|
+
|
|
341
|
+
if priority is not None:
|
|
342
|
+
if priority and 1 <= priority <= 9:
|
|
343
|
+
existing_task["PRIORITY"] = priority
|
|
344
|
+
elif "PRIORITY" in existing_task:
|
|
345
|
+
del existing_task["PRIORITY"]
|
|
346
|
+
|
|
347
|
+
if status is not None:
|
|
348
|
+
existing_task["STATUS"] = status.value
|
|
349
|
+
|
|
350
|
+
if percent_complete is not None:
|
|
351
|
+
if 0 <= percent_complete <= 100:
|
|
352
|
+
existing_task["PERCENT-COMPLETE"] = percent_complete
|
|
353
|
+
|
|
354
|
+
# Handle RELATED-TO property updates
|
|
355
|
+
if related_to is not None:
|
|
356
|
+
# Remove all existing RELATED-TO properties
|
|
357
|
+
if "RELATED-TO" in existing_task:
|
|
358
|
+
del existing_task["RELATED-TO"]
|
|
359
|
+
|
|
360
|
+
# Add new RELATED-TO properties if provided
|
|
361
|
+
if related_to:
|
|
362
|
+
for related_uid in related_to:
|
|
363
|
+
existing_task.add("RELATED-TO", related_uid)
|
|
364
|
+
|
|
365
|
+
# Update last-modified timestamp
|
|
366
|
+
if "LAST-MODIFIED" in existing_task:
|
|
367
|
+
del existing_task["LAST-MODIFIED"]
|
|
368
|
+
existing_task.add("LAST-MODIFIED", datetime.now(timezone.utc))
|
|
369
|
+
|
|
370
|
+
# Save the updated task
|
|
371
|
+
caldav_task.data = ical.to_ical().decode("utf-8")
|
|
372
|
+
caldav_task.save()
|
|
373
|
+
|
|
374
|
+
# Parse and return the updated task
|
|
375
|
+
return self._parse_caldav_task(caldav_task, calendar_uid, account_alias)
|
|
376
|
+
|
|
377
|
+
except TaskNotFoundError:
|
|
378
|
+
raise
|
|
379
|
+
except EventCreationError:
|
|
380
|
+
raise
|
|
381
|
+
except Exception as e:
|
|
382
|
+
logger.error(
|
|
383
|
+
f"Error updating task '{task_uid}': {e}",
|
|
384
|
+
extra={"request_id": request_id},
|
|
385
|
+
)
|
|
386
|
+
raise EventCreationError(
|
|
387
|
+
task_uid, f"Failed to update task: {str(e)}", request_id=request_id
|
|
388
|
+
)
|
|
389
|
+
|
|
390
|
+
def delete_task(
|
|
391
|
+
self,
|
|
392
|
+
calendar_uid: str,
|
|
393
|
+
task_uid: str,
|
|
394
|
+
account_alias: Optional[str] = None,
|
|
395
|
+
request_id: Optional[str] = None,
|
|
396
|
+
) -> bool:
|
|
397
|
+
"""Delete a task by UID - raises exceptions on failure"""
|
|
398
|
+
request_id = request_id or str(uuid.uuid4())
|
|
399
|
+
|
|
400
|
+
calendar = self.calendars.get_calendar(
|
|
401
|
+
calendar_uid, account_alias, request_id=request_id
|
|
402
|
+
)
|
|
403
|
+
if not calendar:
|
|
404
|
+
raise CalendarNotFoundError(
|
|
405
|
+
calendar_uid, account_alias, request_id=request_id
|
|
406
|
+
)
|
|
407
|
+
|
|
408
|
+
try:
|
|
409
|
+
# Use utility function to find task with automatic fallback
|
|
410
|
+
task = get_item_with_fallback(
|
|
411
|
+
calendar, task_uid, "task", request_id=request_id
|
|
412
|
+
)
|
|
413
|
+
task.delete()
|
|
414
|
+
logger.info(
|
|
415
|
+
f"Deleted task '{task_uid}'",
|
|
416
|
+
extra={"request_id": request_id},
|
|
417
|
+
)
|
|
418
|
+
return True
|
|
419
|
+
except ValueError:
|
|
420
|
+
# get_item_with_fallback raises ValueError when not found
|
|
421
|
+
raise TaskNotFoundError(task_uid, calendar_uid, request_id=request_id)
|
|
422
|
+
|
|
423
|
+
except TaskNotFoundError:
|
|
424
|
+
raise
|
|
425
|
+
except caldav.lib.error.AuthorizationError as e:
|
|
426
|
+
logger.error(
|
|
427
|
+
f"Authorization error deleting task '{task_uid}': {e}",
|
|
428
|
+
extra={"request_id": request_id},
|
|
429
|
+
)
|
|
430
|
+
raise EventDeletionError(
|
|
431
|
+
task_uid, "Authorization failed", request_id=request_id
|
|
432
|
+
)
|
|
433
|
+
except Exception as e:
|
|
434
|
+
logger.error(
|
|
435
|
+
f"Error deleting task '{task_uid}': {e}",
|
|
436
|
+
extra={"request_id": request_id},
|
|
437
|
+
)
|
|
438
|
+
raise EventDeletionError(task_uid, str(e), request_id=request_id)
|
|
439
|
+
|
|
440
|
+
def _parse_caldav_task(
|
|
441
|
+
self, caldav_event: CalDAVEvent, calendar_uid: str, account_alias: Optional[str]
|
|
442
|
+
) -> Optional[Task]:
|
|
443
|
+
"""Parse CalDAV VTODO to Task model"""
|
|
444
|
+
try:
|
|
445
|
+
# Parse iCalendar data
|
|
446
|
+
ical = iCalendar.from_ical(caldav_event.data)
|
|
447
|
+
|
|
448
|
+
for component in ical.walk():
|
|
449
|
+
if component.name == "VTODO":
|
|
450
|
+
# Parse date/time values
|
|
451
|
+
due_dt = None
|
|
452
|
+
completed_dt = None
|
|
453
|
+
|
|
454
|
+
if component.get("due"):
|
|
455
|
+
due_dt = ical_to_datetime(component.get("due"))
|
|
456
|
+
if component.get("completed"):
|
|
457
|
+
completed_dt = ical_to_datetime(component.get("completed"))
|
|
458
|
+
|
|
459
|
+
# Parse priority
|
|
460
|
+
priority = None
|
|
461
|
+
if component.get("priority"):
|
|
462
|
+
try:
|
|
463
|
+
priority = int(component.get("priority"))
|
|
464
|
+
except (ValueError, TypeError):
|
|
465
|
+
priority = None
|
|
466
|
+
|
|
467
|
+
# Parse percent complete
|
|
468
|
+
percent_complete = 0
|
|
469
|
+
if component.get("percent-complete"):
|
|
470
|
+
try:
|
|
471
|
+
percent_complete = int(component.get("percent-complete"))
|
|
472
|
+
except (ValueError, TypeError):
|
|
473
|
+
percent_complete = 0
|
|
474
|
+
|
|
475
|
+
# Parse status
|
|
476
|
+
status = TaskStatus.NEEDS_ACTION
|
|
477
|
+
if component.get("status"):
|
|
478
|
+
try:
|
|
479
|
+
status = TaskStatus(str(component.get("status")))
|
|
480
|
+
except ValueError:
|
|
481
|
+
status = TaskStatus.NEEDS_ACTION
|
|
482
|
+
|
|
483
|
+
# Parse RELATED-TO properties
|
|
484
|
+
related_to = []
|
|
485
|
+
if component.get("related-to"):
|
|
486
|
+
related_prop = component.get("related-to")
|
|
487
|
+
if isinstance(related_prop, list):
|
|
488
|
+
related_to = [str(r) for r in related_prop]
|
|
489
|
+
else:
|
|
490
|
+
related_to = [str(related_prop)]
|
|
491
|
+
|
|
492
|
+
# Parse basic task data
|
|
493
|
+
task = Task(
|
|
494
|
+
uid=str(component.get("uid", "")),
|
|
495
|
+
summary=str(component.get("summary", "No Title")),
|
|
496
|
+
description=(
|
|
497
|
+
str(component.get("description", ""))
|
|
498
|
+
if component.get("description")
|
|
499
|
+
else None
|
|
500
|
+
),
|
|
501
|
+
due=due_dt,
|
|
502
|
+
completed=completed_dt,
|
|
503
|
+
priority=priority,
|
|
504
|
+
status=status,
|
|
505
|
+
percent_complete=percent_complete,
|
|
506
|
+
related_to=related_to,
|
|
507
|
+
calendar_uid=calendar_uid,
|
|
508
|
+
account_alias=account_alias
|
|
509
|
+
or self._get_default_account()
|
|
510
|
+
or "default",
|
|
511
|
+
)
|
|
512
|
+
|
|
513
|
+
return task
|
|
514
|
+
|
|
515
|
+
except Exception as e:
|
|
516
|
+
logger.error(f"Error parsing task: {e}")
|
|
517
|
+
|
|
518
|
+
return None
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Chronos MCP Tools - Modular tool definitions
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from .accounts import register_account_tools
|
|
6
|
+
from .bulk import register_bulk_tools
|
|
7
|
+
from .calendars import register_calendar_tools
|
|
8
|
+
from .events import register_event_tools
|
|
9
|
+
from .journals import register_journal_tools
|
|
10
|
+
from .tasks import register_task_tools
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"register_account_tools",
|
|
14
|
+
"register_calendar_tools",
|
|
15
|
+
"register_event_tools",
|
|
16
|
+
"register_task_tools",
|
|
17
|
+
"register_journal_tools",
|
|
18
|
+
"register_bulk_tools",
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def register_all_tools(mcp, managers):
|
|
23
|
+
"""Register all tool modules with the MCP server"""
|
|
24
|
+
register_account_tools(mcp, managers)
|
|
25
|
+
register_calendar_tools(mcp, managers)
|
|
26
|
+
register_event_tools(mcp, managers)
|
|
27
|
+
register_task_tools(mcp, managers)
|
|
28
|
+
register_journal_tools(mcp, managers)
|
|
29
|
+
register_bulk_tools(mcp, managers)
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Account management tools for Chronos MCP
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from typing import Any, Dict, Optional
|
|
6
|
+
|
|
7
|
+
from pydantic import Field
|
|
8
|
+
|
|
9
|
+
from ..exceptions import (
|
|
10
|
+
AccountAlreadyExistsError,
|
|
11
|
+
AccountNotFoundError,
|
|
12
|
+
ValidationError,
|
|
13
|
+
)
|
|
14
|
+
from ..models import Account
|
|
15
|
+
from ..validation import InputValidator
|
|
16
|
+
from .base import create_success_response, handle_tool_errors
|
|
17
|
+
|
|
18
|
+
# Module-level managers dictionary for dependency injection
|
|
19
|
+
_managers = {}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
# Account tool functions - defined as standalone functions for importability
|
|
23
|
+
@handle_tool_errors
|
|
24
|
+
async def add_account(
|
|
25
|
+
alias: str = Field(..., description="Unique alias for the account"),
|
|
26
|
+
url: str = Field(..., description="CalDAV server URL"),
|
|
27
|
+
username: str = Field(..., description="Username for authentication"),
|
|
28
|
+
password: str = Field(..., description="Password for authentication"),
|
|
29
|
+
display_name: Optional[str] = Field(
|
|
30
|
+
None, description="Display name for the account"
|
|
31
|
+
),
|
|
32
|
+
allow_local: bool = Field(
|
|
33
|
+
False,
|
|
34
|
+
description="Allow localhost/private IPs (WARNING: only for development/testing)",
|
|
35
|
+
),
|
|
36
|
+
request_id: str = None,
|
|
37
|
+
) -> Dict[str, Any]:
|
|
38
|
+
"""Add a new CalDAV account to Chronos
|
|
39
|
+
|
|
40
|
+
By default, this function blocks URLs pointing to localhost and private IP
|
|
41
|
+
addresses for security (SSRF protection). For local development or testing,
|
|
42
|
+
set allow_local=True to explicitly allow these addresses.
|
|
43
|
+
"""
|
|
44
|
+
# Validate inputs before creating account
|
|
45
|
+
# SSRF protection is enabled by default (allow_private_ips defaults to False)
|
|
46
|
+
url = InputValidator.validate_url(
|
|
47
|
+
url, allow_private_ips=allow_local, field_name="url"
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
alias = InputValidator.validate_text_field(alias, "alias", required=True)
|
|
51
|
+
username = InputValidator.validate_text_field(username, "username", required=True)
|
|
52
|
+
password = InputValidator.validate_text_field(password, "password", required=True)
|
|
53
|
+
display_name = InputValidator.validate_text_field(
|
|
54
|
+
display_name or alias, "display_name"
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
account = Account(
|
|
58
|
+
alias=alias,
|
|
59
|
+
url=url,
|
|
60
|
+
username=username,
|
|
61
|
+
password=password,
|
|
62
|
+
display_name=display_name or alias,
|
|
63
|
+
)
|
|
64
|
+
_managers["config_manager"].add_account(account)
|
|
65
|
+
|
|
66
|
+
test_result = _managers["account_manager"].test_account(
|
|
67
|
+
alias, request_id=request_id
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
return create_success_response(
|
|
71
|
+
message=f"Account '{alias}' added successfully",
|
|
72
|
+
request_id=request_id,
|
|
73
|
+
alias=alias,
|
|
74
|
+
connected=test_result["connected"],
|
|
75
|
+
calendars=test_result["calendars"],
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
async def list_accounts() -> Dict[str, Any]:
|
|
80
|
+
"""List all configured CalDAV accounts"""
|
|
81
|
+
accounts = _managers["config_manager"].list_accounts()
|
|
82
|
+
|
|
83
|
+
return {
|
|
84
|
+
"accounts": [
|
|
85
|
+
{
|
|
86
|
+
"alias": alias,
|
|
87
|
+
"url": str(acc.url),
|
|
88
|
+
"display_name": acc.display_name,
|
|
89
|
+
"status": acc.status,
|
|
90
|
+
"is_default": alias
|
|
91
|
+
== _managers["config_manager"].config.default_account,
|
|
92
|
+
}
|
|
93
|
+
for alias, acc in accounts.items()
|
|
94
|
+
],
|
|
95
|
+
"total": len(accounts),
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@handle_tool_errors
|
|
100
|
+
async def remove_account(
|
|
101
|
+
alias: str = Field(..., description="Account alias to remove"),
|
|
102
|
+
request_id: str = None,
|
|
103
|
+
) -> Dict[str, Any]:
|
|
104
|
+
"""Remove a CalDAV account from Chronos"""
|
|
105
|
+
if not _managers["config_manager"].get_account(alias):
|
|
106
|
+
raise AccountNotFoundError(alias, request_id=request_id)
|
|
107
|
+
|
|
108
|
+
_managers["account_manager"].disconnect_account(alias)
|
|
109
|
+
_managers["config_manager"].remove_account(alias)
|
|
110
|
+
|
|
111
|
+
return create_success_response(
|
|
112
|
+
message=f"Account '{alias}' removed successfully",
|
|
113
|
+
request_id=request_id,
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
async def test_account(
|
|
118
|
+
alias: str = Field(..., description="Account alias to test"),
|
|
119
|
+
) -> Dict[str, Any]:
|
|
120
|
+
"""Test connectivity to a CalDAV account"""
|
|
121
|
+
return _managers["account_manager"].test_account(alias)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def register_account_tools(mcp, managers):
|
|
125
|
+
"""Register account management tools with the MCP server"""
|
|
126
|
+
|
|
127
|
+
# Update module-level managers for dependency injection
|
|
128
|
+
_managers.update(managers)
|
|
129
|
+
|
|
130
|
+
# Register all account tools with the MCP server
|
|
131
|
+
mcp.tool(add_account)
|
|
132
|
+
mcp.tool(list_accounts)
|
|
133
|
+
mcp.tool(remove_account)
|
|
134
|
+
mcp.tool(test_account)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
# Add .fn attribute to each function for backwards compatibility with tests
|
|
138
|
+
add_account.fn = add_account
|
|
139
|
+
list_accounts.fn = list_accounts
|
|
140
|
+
remove_account.fn = remove_account
|
|
141
|
+
test_account.fn = test_account
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
# Export all tools for backwards compatibility
|
|
145
|
+
__all__ = [
|
|
146
|
+
"add_account",
|
|
147
|
+
"list_accounts",
|
|
148
|
+
"remove_account",
|
|
149
|
+
"test_account",
|
|
150
|
+
"register_account_tools",
|
|
151
|
+
]
|