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.
Files changed (68) hide show
  1. chronos_mcp/__init__.py +5 -0
  2. chronos_mcp/__main__.py +9 -0
  3. chronos_mcp/accounts.py +410 -0
  4. chronos_mcp/bulk.py +946 -0
  5. chronos_mcp/caldav_utils.py +149 -0
  6. chronos_mcp/calendars.py +204 -0
  7. chronos_mcp/config.py +187 -0
  8. chronos_mcp/credentials.py +190 -0
  9. chronos_mcp/events.py +515 -0
  10. chronos_mcp/exceptions.py +477 -0
  11. chronos_mcp/journals.py +477 -0
  12. chronos_mcp/logging_config.py +23 -0
  13. chronos_mcp/models.py +202 -0
  14. chronos_mcp/py.typed +0 -0
  15. chronos_mcp/rrule.py +259 -0
  16. chronos_mcp/search.py +315 -0
  17. chronos_mcp/server.py +121 -0
  18. chronos_mcp/tasks.py +518 -0
  19. chronos_mcp/tools/__init__.py +29 -0
  20. chronos_mcp/tools/accounts.py +151 -0
  21. chronos_mcp/tools/base.py +59 -0
  22. chronos_mcp/tools/bulk.py +557 -0
  23. chronos_mcp/tools/calendars.py +142 -0
  24. chronos_mcp/tools/events.py +698 -0
  25. chronos_mcp/tools/journals.py +310 -0
  26. chronos_mcp/tools/tasks.py +414 -0
  27. chronos_mcp/utils.py +163 -0
  28. chronos_mcp/validation.py +636 -0
  29. iflow_mcp_democratize_technology_chronos_mcp-2.0.0.dist-info/METADATA +299 -0
  30. iflow_mcp_democratize_technology_chronos_mcp-2.0.0.dist-info/RECORD +68 -0
  31. iflow_mcp_democratize_technology_chronos_mcp-2.0.0.dist-info/WHEEL +5 -0
  32. iflow_mcp_democratize_technology_chronos_mcp-2.0.0.dist-info/entry_points.txt +2 -0
  33. iflow_mcp_democratize_technology_chronos_mcp-2.0.0.dist-info/licenses/LICENSE +21 -0
  34. iflow_mcp_democratize_technology_chronos_mcp-2.0.0.dist-info/top_level.txt +2 -0
  35. tests/__init__.py +0 -0
  36. tests/conftest.py +91 -0
  37. tests/unit/__init__.py +0 -0
  38. tests/unit/test_accounts.py +380 -0
  39. tests/unit/test_accounts_ssrf.py +134 -0
  40. tests/unit/test_base.py +135 -0
  41. tests/unit/test_bulk.py +380 -0
  42. tests/unit/test_bulk_create.py +408 -0
  43. tests/unit/test_bulk_delete.py +341 -0
  44. tests/unit/test_bulk_resource_limits.py +74 -0
  45. tests/unit/test_caldav_utils.py +300 -0
  46. tests/unit/test_calendars.py +286 -0
  47. tests/unit/test_config.py +111 -0
  48. tests/unit/test_config_validation.py +128 -0
  49. tests/unit/test_credentials_security.py +189 -0
  50. tests/unit/test_cryptography_security.py +178 -0
  51. tests/unit/test_events.py +536 -0
  52. tests/unit/test_exceptions.py +58 -0
  53. tests/unit/test_journals.py +1097 -0
  54. tests/unit/test_models.py +95 -0
  55. tests/unit/test_race_conditions.py +202 -0
  56. tests/unit/test_recurring_events.py +156 -0
  57. tests/unit/test_rrule.py +217 -0
  58. tests/unit/test_search.py +372 -0
  59. tests/unit/test_search_advanced.py +333 -0
  60. tests/unit/test_server_input_validation.py +219 -0
  61. tests/unit/test_ssrf_protection.py +505 -0
  62. tests/unit/test_tasks.py +918 -0
  63. tests/unit/test_thread_safety.py +301 -0
  64. tests/unit/test_tools_journals.py +617 -0
  65. tests/unit/test_tools_tasks.py +968 -0
  66. tests/unit/test_url_validation_security.py +234 -0
  67. tests/unit/test_utils.py +180 -0
  68. 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
+ ]