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/py.typed ADDED
File without changes
chronos_mcp/rrule.py ADDED
@@ -0,0 +1,259 @@
1
+ """RRULE validation and parsing for recurring events.
2
+
3
+ This module provides utilities for validating and working with
4
+ iCalendar RRULE (recurrence rule) strings used in recurring events.
5
+ """
6
+
7
+ import logging
8
+ from datetime import datetime, timezone
9
+ from typing import Any, Dict, List, Optional, Tuple
10
+
11
+ from dateutil.rrule import DAILY, MONTHLY, WEEKLY, YEARLY, rrulestr
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+ # Maximum values for safety
16
+ MAX_COUNT = 365 # Maximum number of occurrences
17
+ MAX_YEARS_AHEAD = 2 # Maximum years into the future
18
+ MIN_INTERVAL_SECONDS = 3600 # Minimum 1 hour between occurrences
19
+ MAX_INSTANCES_TO_EXPAND = 1000 # Maximum instances to expand at once
20
+
21
+
22
+ class RRuleValidator:
23
+ """Validate and parse RRULE strings for recurring events."""
24
+
25
+ # Allowed frequencies (no SECONDLY or MINUTELY for performance)
26
+ ALLOWED_FREQUENCIES = {
27
+ "DAILY": DAILY,
28
+ "WEEKLY": WEEKLY,
29
+ "MONTHLY": MONTHLY,
30
+ "YEARLY": YEARLY,
31
+ }
32
+
33
+ @classmethod
34
+ def validate_rrule(cls, rrule_string: str) -> Tuple[bool, Optional[str]]:
35
+ """
36
+ Validate an RRULE string for safety and correctness.
37
+
38
+ Args:
39
+ rrule_string: The RRULE string to validate
40
+
41
+ Returns:
42
+ Tuple of (is_valid, error_message)
43
+ """
44
+ if not rrule_string:
45
+ return False, "RRULE cannot be empty"
46
+
47
+ try:
48
+ # Basic format check
49
+ if not rrule_string.startswith("FREQ="):
50
+ return False, "RRULE must start with FREQ="
51
+
52
+ # Parse the rule to check validity
53
+ rule = rrulestr(rrule_string)
54
+
55
+ # Extract frequency
56
+ freq_str = None
57
+ for part in rrule_string.split(";"):
58
+ if part.startswith("FREQ="):
59
+ freq_str = part.split("=")[1]
60
+ break
61
+
62
+ if freq_str not in cls.ALLOWED_FREQUENCIES:
63
+ return (
64
+ False,
65
+ f"Frequency {freq_str} not allowed. Use: {', '.join(cls.ALLOWED_FREQUENCIES.keys())}",
66
+ )
67
+
68
+ # Check for end condition (COUNT or UNTIL)
69
+ has_count = "COUNT=" in rrule_string
70
+ has_until = "UNTIL=" in rrule_string
71
+
72
+ if not has_count and not has_until:
73
+ return (
74
+ False,
75
+ "RRULE must have COUNT or UNTIL to prevent infinite recurrence",
76
+ )
77
+
78
+ # Validate COUNT if present
79
+ if has_count:
80
+ count_value = cls._extract_value(rrule_string, "COUNT")
81
+ if count_value:
82
+ try:
83
+ count = int(count_value)
84
+ if count > MAX_COUNT:
85
+ return False, f"COUNT cannot exceed {MAX_COUNT}"
86
+ if count < 1:
87
+ return False, "COUNT must be at least 1"
88
+ except ValueError:
89
+ return False, "COUNT must be a valid integer"
90
+
91
+ # Validate UNTIL if present
92
+ if has_until:
93
+ until_value = cls._extract_value(rrule_string, "UNTIL")
94
+ if until_value:
95
+ try:
96
+ # Parse the until date
97
+ if "T" in until_value:
98
+ until_dt = datetime.strptime(until_value, "%Y%m%dT%H%M%SZ")
99
+ else:
100
+ until_dt = datetime.strptime(until_value, "%Y%m%d")
101
+
102
+ # Ensure it's timezone aware
103
+ if until_dt.tzinfo is None:
104
+ until_dt = until_dt.replace(tzinfo=timezone.utc)
105
+
106
+ # Check it's not too far in the future
107
+ now = datetime.now(timezone.utc)
108
+ max_future = now.replace(year=now.year + MAX_YEARS_AHEAD)
109
+
110
+ if until_dt > max_future:
111
+ return (
112
+ False,
113
+ f"UNTIL date cannot be more than {MAX_YEARS_AHEAD} years in the future",
114
+ )
115
+
116
+ except ValueError:
117
+ return (
118
+ False,
119
+ "UNTIL must be a valid date in YYYYMMDD or YYYYMMDDTHHMMSSZ format",
120
+ )
121
+
122
+ # Validate INTERVAL if present
123
+ if "INTERVAL=" in rrule_string:
124
+ interval_value = cls._extract_value(rrule_string, "INTERVAL")
125
+ if interval_value:
126
+ try:
127
+ interval = int(interval_value)
128
+ if interval < 1:
129
+ return False, "INTERVAL must be at least 1"
130
+ # Check minimum interval based on frequency
131
+ if freq_str == "DAILY" and interval > 365:
132
+ return False, "Daily INTERVAL cannot exceed 365"
133
+ except ValueError:
134
+ return False, "INTERVAL must be a valid integer"
135
+
136
+ return True, None
137
+
138
+ except Exception as e:
139
+ logger.error(f"Error validating RRULE: {str(e)}")
140
+ return False, f"Invalid RRULE format: {str(e)}"
141
+
142
+ @staticmethod
143
+ def _extract_value(rrule_string: str, param: str) -> Optional[str]:
144
+ """Extract a parameter value from an RRULE string."""
145
+ for part in rrule_string.split(";"):
146
+ if part.startswith(f"{param}="):
147
+ return part.split("=")[1]
148
+ return None
149
+
150
+ @classmethod
151
+ def expand_occurrences(
152
+ cls,
153
+ rrule_string: str,
154
+ start_date: datetime,
155
+ end_date: Optional[datetime] = None,
156
+ limit: int = MAX_INSTANCES_TO_EXPAND,
157
+ ) -> List[datetime]:
158
+ """
159
+ Expand recurring rule to individual occurrences.
160
+ Args:
161
+ rrule_string: The RRULE string
162
+ start_date: Start date for the recurrence
163
+ end_date: Optional end date to limit occurrences
164
+ limit: Maximum number of occurrences to return
165
+
166
+ Returns:
167
+ List of datetime objects representing occurrences
168
+ """
169
+ try:
170
+ # Ensure start_date has timezone
171
+ if start_date.tzinfo is None:
172
+ start_date = start_date.replace(tzinfo=timezone.utc)
173
+
174
+ # Parse the rule
175
+ rule = rrulestr(rrule_string, dtstart=start_date)
176
+
177
+ # Generate occurrences
178
+ occurrences = []
179
+ for i, occurrence in enumerate(rule):
180
+ if i >= limit:
181
+ break
182
+
183
+ if end_date and occurrence > end_date:
184
+ break
185
+
186
+ occurrences.append(occurrence)
187
+
188
+ return occurrences
189
+
190
+ except Exception as e:
191
+ logger.error(f"Error expanding RRULE occurrences: {str(e)}")
192
+ return []
193
+
194
+ @classmethod
195
+ def get_rrule_info(cls, rrule_string: str) -> Dict[str, Any]:
196
+ """
197
+ Extract information from an RRULE string.
198
+
199
+ Args:
200
+ rrule_string: The RRULE string to parse
201
+
202
+ Returns:
203
+ Dictionary with RRULE components
204
+ """
205
+ info = {
206
+ "frequency": None,
207
+ "interval": 1,
208
+ "count": None,
209
+ "until": None,
210
+ "byday": None,
211
+ "bymonthday": None,
212
+ "bymonth": None,
213
+ }
214
+
215
+ for part in rrule_string.split(";"):
216
+ if "=" in part:
217
+ key, value = part.split("=", 1)
218
+ key_lower = key.lower()
219
+
220
+ if key == "FREQ":
221
+ info["frequency"] = value
222
+ elif key == "INTERVAL":
223
+ info["interval"] = int(value)
224
+ elif key == "COUNT":
225
+ info["count"] = int(value)
226
+ elif key == "UNTIL":
227
+ info["until"] = value
228
+ elif key == "BYDAY":
229
+ info["byday"] = value.split(",")
230
+ elif key == "BYMONTHDAY":
231
+ info["bymonthday"] = [int(d) for d in value.split(",")]
232
+ elif key == "BYMONTH":
233
+ info["bymonth"] = [int(m) for m in value.split(",")]
234
+
235
+ return info
236
+
237
+
238
+ # Common RRULE patterns for convenience
239
+ class RRuleTemplates:
240
+ """Common RRULE templates for recurring events."""
241
+
242
+ # Daily patterns
243
+ DAILY_WEEKDAYS = "FREQ=DAILY;BYDAY=MO,TU,WE,TH,FR"
244
+ DAILY_FOREVER = "FREQ=DAILY" # Needs COUNT or UNTIL added
245
+
246
+ # Weekly patterns
247
+ WEEKLY_ON_DAY = "FREQ=WEEKLY;BYDAY={day}" # Replace {day} with MO, TU, etc.
248
+ WEEKLY_MULTIPLE_DAYS = (
249
+ "FREQ=WEEKLY;BYDAY={days}" # Replace {days} with comma-separated
250
+ )
251
+
252
+ # Monthly patterns
253
+ MONTHLY_ON_DATE = "FREQ=MONTHLY;BYMONTHDAY={day}" # Replace {day} with 1-31
254
+ MONTHLY_LAST_DAY = "FREQ=MONTHLY;BYMONTHDAY=-1"
255
+ MONTHLY_FIRST_WEEKDAY = "FREQ=MONTHLY;BYDAY=1{day}" # e.g., 1MO for first Monday
256
+
257
+ # Yearly patterns
258
+ YEARLY_ON_DATE = "FREQ=YEARLY"
259
+ YEARLY_ON_MONTH_DAY = "FREQ=YEARLY;BYMONTH={month};BYMONTHDAY={day}"
chronos_mcp/search.py ADDED
@@ -0,0 +1,315 @@
1
+ """CalDAV component search functionality for Chronos MCP (Events, Tasks, Journals)."""
2
+
3
+ import re
4
+ from dataclasses import dataclass, field
5
+ from datetime import datetime
6
+ from typing import Any, Dict, List, Optional, Tuple
7
+
8
+
9
+ @dataclass
10
+ class SearchOptions:
11
+ """Options for CalDAV component search functionality."""
12
+
13
+ query: str
14
+ fields: List[str]
15
+ component_types: List[str] = field(default_factory=lambda: ["VEVENT"])
16
+ case_sensitive: bool = False
17
+ match_type: str = "contains"
18
+ use_regex: bool = False
19
+ date_start: Optional[datetime] = None
20
+ date_end: Optional[datetime] = None
21
+ max_results: Optional[int] = None
22
+
23
+ def __post_init__(self):
24
+ valid_types = ["contains", "starts_with", "ends_with", "exact", "regex"]
25
+ if self.match_type not in valid_types:
26
+ raise ValueError(f"match_type must be one of {valid_types}")
27
+
28
+ valid_components = ["VEVENT", "VTODO", "VJOURNAL"]
29
+ for comp_type in self.component_types:
30
+ if comp_type not in valid_components:
31
+ raise ValueError(f"component_type must be one of {valid_components}")
32
+
33
+ # Set default fields based on component types if not provided
34
+ if not self.fields:
35
+ self.fields = self._get_default_fields()
36
+
37
+ if self.use_regex or self.match_type == "regex":
38
+ flags = 0 if self.case_sensitive else re.IGNORECASE
39
+ self.pattern = re.compile(self.query, flags)
40
+
41
+ def _get_default_fields(self) -> List[str]:
42
+ """Get default search fields based on component types."""
43
+ fields = set(["summary", "description"]) # Common fields
44
+
45
+ if "VEVENT" in self.component_types:
46
+ fields.update(["location"])
47
+
48
+ if "VTODO" in self.component_types:
49
+ fields.update(["due", "priority", "status", "percent_complete"])
50
+
51
+ if "VJOURNAL" in self.component_types:
52
+ fields.update(["dtstart", "categories"])
53
+
54
+ return list(fields)
55
+
56
+
57
+ def _matches_component_type(component: Dict[str, Any], options: SearchOptions) -> bool:
58
+ """Check if component matches the requested component types."""
59
+ component_type = component.get(
60
+ "component_type", "VEVENT"
61
+ ) # Default to VEVENT for backward compatibility
62
+
63
+ # For legacy support, try to infer component type from fields
64
+ if component_type == "VEVENT" and not component.get("component_type"):
65
+ if (
66
+ "due" in component
67
+ or "priority" in component
68
+ or "percent_complete" in component
69
+ ):
70
+ component_type = "VTODO"
71
+ elif (
72
+ "dtstart" in component
73
+ and "categories" in component
74
+ and not component.get("dtend")
75
+ ):
76
+ component_type = "VJOURNAL"
77
+
78
+ return component_type in options.component_types
79
+
80
+
81
+ def search_components(
82
+ components: List[Dict[str, Any]], options: SearchOptions
83
+ ) -> List[Dict[str, Any]]:
84
+ """Search CalDAV components (events, tasks, journals) based on provided options."""
85
+ if not options.query and not (options.date_start or options.date_end):
86
+ # Filter by component type even if no query
87
+ return [comp for comp in components if _matches_component_type(comp, options)]
88
+
89
+ def matches_text(component: Dict[str, Any]) -> bool:
90
+ if not options.query:
91
+ return True
92
+
93
+ for field in options.fields:
94
+ value = component.get(field, "")
95
+ if value is None:
96
+ continue
97
+
98
+ # Handle special field formatting
99
+ if field == "categories" and isinstance(value, list):
100
+ value_str = " ".join(str(v) for v in value)
101
+ elif field in ["priority", "percent_complete"] and value is not None:
102
+ value_str = str(value)
103
+ else:
104
+ value_str = str(value)
105
+
106
+ if not options.case_sensitive:
107
+ value_str = value_str.lower()
108
+ query = options.query.lower()
109
+ else:
110
+ query = options.query
111
+
112
+ if options.use_regex or options.match_type == "regex":
113
+ if options.pattern.search(value_str):
114
+ return True
115
+ elif options.match_type == "contains":
116
+ if query in value_str:
117
+ return True
118
+ elif options.match_type == "starts_with":
119
+ if value_str.startswith(query):
120
+ return True
121
+ elif options.match_type == "ends_with":
122
+ if value_str.endswith(query):
123
+ return True
124
+ elif options.match_type == "exact":
125
+ if value_str == query:
126
+ return True
127
+
128
+ return False
129
+
130
+ def matches_date(component: Dict[str, Any]) -> bool:
131
+ if not (options.date_start or options.date_end):
132
+ return True
133
+
134
+ # Try different date fields based on component type
135
+ date_field = None
136
+ if component.get("component_type") == "VTODO" or "due" in component:
137
+ date_field = component.get("due")
138
+ elif component.get("component_type") == "VJOURNAL" or (
139
+ "dtstart" in component and not component.get("dtend")
140
+ ):
141
+ date_field = component.get("dtstart")
142
+ else: # VEVENT
143
+ date_field = component.get("dtstart") or component.get("start")
144
+
145
+ if not date_field:
146
+ return False
147
+
148
+ if isinstance(date_field, str):
149
+ date_field = datetime.fromisoformat(date_field.replace("Z", "+00:00"))
150
+
151
+ if options.date_start and date_field < options.date_start:
152
+ return False
153
+ if options.date_end and date_field > options.date_end:
154
+ return False
155
+
156
+ return True
157
+
158
+ results = [
159
+ component
160
+ for component in components
161
+ if _matches_component_type(component, options)
162
+ and matches_text(component)
163
+ and matches_date(component)
164
+ ]
165
+
166
+ if options.max_results:
167
+ results = results[: options.max_results]
168
+
169
+ return results
170
+
171
+
172
+ def calculate_relevance_score(
173
+ component: Dict[str, Any], options: SearchOptions, current_time: datetime = None
174
+ ) -> float:
175
+ """Calculate relevance score for search ranking."""
176
+ if current_time is None:
177
+ current_time = datetime.now()
178
+
179
+ score = 0.0
180
+ query = options.query.lower() if not options.case_sensitive else options.query
181
+
182
+ field_weights = {
183
+ "summary": 3.0,
184
+ "description": 2.0,
185
+ "location": 1.0,
186
+ "due": 2.5,
187
+ "priority": 1.5,
188
+ "status": 1.0,
189
+ "percent_complete": 1.0,
190
+ "dtstart": 2.0,
191
+ "categories": 1.5,
192
+ }
193
+
194
+ for field in options.fields:
195
+ if field not in field_weights:
196
+ continue
197
+
198
+ value = component.get(field, "")
199
+ if not value:
200
+ continue
201
+
202
+ # Handle special field formatting for scoring
203
+ if field == "categories" and isinstance(value, list):
204
+ value_str = " ".join(str(v) for v in value)
205
+ elif field in ["priority", "percent_complete"] and value is not None:
206
+ value_str = str(value)
207
+ else:
208
+ value_str = str(value)
209
+
210
+ if not options.case_sensitive:
211
+ value_str = value_str.lower()
212
+
213
+ field_score = 0.0
214
+
215
+ if options.use_regex or options.match_type == "regex":
216
+ matches = list(options.pattern.finditer(value_str))
217
+ if matches:
218
+ field_score = 2.0 * len(matches)
219
+ first_match_pos = matches[0].start()
220
+ position_factor = 1.0 - (first_match_pos / max(len(value_str), 1))
221
+ field_score *= 1.0 + position_factor * 0.5
222
+ else:
223
+ if options.match_type == "exact" and value_str == query:
224
+ field_score = 5.0
225
+ elif options.match_type == "starts_with" and value_str.startswith(query):
226
+ field_score = 3.0
227
+ elif options.match_type == "contains" or query in value_str:
228
+ occurrences = value_str.count(query)
229
+ if occurrences > 0:
230
+ field_score = 1.0 * occurrences
231
+ first_pos = value_str.find(query)
232
+ position_factor = 1.0 - (first_pos / max(len(value_str), 1))
233
+ field_score *= 1.0 + position_factor * 0.5
234
+
235
+ score += field_score * field_weights.get(field, 1.0)
236
+
237
+ # Recency boost - use appropriate date field based on component type
238
+ date_field = None
239
+ if component.get("component_type") == "VTODO" or "due" in component:
240
+ date_field = component.get("due")
241
+ elif component.get("component_type") == "VJOURNAL" or (
242
+ "dtstart" in component and not component.get("dtend")
243
+ ):
244
+ date_field = component.get("dtstart")
245
+ else: # VEVENT
246
+ date_field = component.get("dtstart") or component.get("start")
247
+
248
+ if date_field:
249
+ if isinstance(date_field, str):
250
+ date_field = datetime.fromisoformat(date_field.replace("Z", "+00:00"))
251
+
252
+ days_diff = abs((current_time - date_field).days)
253
+ if days_diff <= 30:
254
+ recency_boost = 0.1 * (1.0 - days_diff / 30.0)
255
+ score *= 1.0 + recency_boost
256
+
257
+ return score
258
+
259
+
260
+ def search_components_ranked(
261
+ components: List[Dict[str, Any]], options: SearchOptions
262
+ ) -> List[Tuple[Dict[str, Any], float]]:
263
+ """Search CalDAV components and return them with relevance scores."""
264
+ matching_components = search_components(components, options)
265
+
266
+ scored_components = []
267
+ for component in matching_components:
268
+ score = calculate_relevance_score(component, options)
269
+ scored_components.append((component, score))
270
+
271
+ scored_components.sort(key=lambda x: x[1], reverse=True)
272
+
273
+ if options.max_results:
274
+ scored_components = scored_components[: options.max_results]
275
+
276
+ return scored_components
277
+
278
+
279
+ # Backward compatibility functions
280
+ def search_events(
281
+ events: List[Dict[str, Any]], options: SearchOptions
282
+ ) -> List[Dict[str, Any]]:
283
+ """Search events - backward compatibility wrapper."""
284
+ # Ensure we're only searching events for backward compatibility
285
+ event_options = SearchOptions(
286
+ query=options.query,
287
+ fields=options.fields,
288
+ component_types=["VEVENT"],
289
+ case_sensitive=options.case_sensitive,
290
+ match_type=options.match_type,
291
+ use_regex=options.use_regex,
292
+ date_start=options.date_start,
293
+ date_end=options.date_end,
294
+ max_results=options.max_results,
295
+ )
296
+ return search_components(events, event_options)
297
+
298
+
299
+ def search_events_ranked(
300
+ events: List[Dict[str, Any]], options: SearchOptions
301
+ ) -> List[Tuple[Dict[str, Any], float]]:
302
+ """Search events and return them with relevance scores - backward compatibility wrapper."""
303
+ # Ensure we're only searching events for backward compatibility
304
+ event_options = SearchOptions(
305
+ query=options.query,
306
+ fields=options.fields,
307
+ component_types=["VEVENT"],
308
+ case_sensitive=options.case_sensitive,
309
+ match_type=options.match_type,
310
+ use_regex=options.use_regex,
311
+ date_start=options.date_start,
312
+ date_end=options.date_end,
313
+ max_results=options.max_results,
314
+ )
315
+ return search_components_ranked(events, event_options)
chronos_mcp/server.py ADDED
@@ -0,0 +1,121 @@
1
+ """
2
+ Chronos MCP Server - Advanced CalDAV Management
3
+ """
4
+
5
+ from fastmcp import FastMCP
6
+
7
+ from .accounts import AccountManager
8
+ from .bulk import BulkOperationManager
9
+ from .calendars import CalendarManager
10
+ from .config import ConfigManager
11
+ from .events import EventManager
12
+ from .journals import JournalManager
13
+ from .logging_config import setup_logging
14
+ from .tasks import TaskManager
15
+ from .tools import register_all_tools
16
+
17
+ logger = setup_logging()
18
+
19
+ mcp = FastMCP("chronos-mcp")
20
+
21
+ logger.info("Initializing Chronos MCP Server...")
22
+
23
+ try:
24
+ config_manager = ConfigManager()
25
+ account_manager = AccountManager(config_manager)
26
+ calendar_manager = CalendarManager(account_manager)
27
+ event_manager = EventManager(calendar_manager)
28
+ task_manager = TaskManager(calendar_manager)
29
+ journal_manager = JournalManager(calendar_manager)
30
+ bulk_manager = BulkOperationManager(
31
+ event_manager=event_manager,
32
+ task_manager=task_manager,
33
+ journal_manager=journal_manager,
34
+ )
35
+ logger.info("All managers initialized successfully")
36
+
37
+ managers = {
38
+ "config_manager": config_manager,
39
+ "account_manager": account_manager,
40
+ "calendar_manager": calendar_manager,
41
+ "event_manager": event_manager,
42
+ "task_manager": task_manager,
43
+ "journal_manager": journal_manager,
44
+ "bulk_manager": bulk_manager,
45
+ }
46
+
47
+ register_all_tools(mcp, managers)
48
+ logger.info("All tools registered successfully")
49
+
50
+ except Exception as e:
51
+ logger.error(f"Error initializing Chronos MCP Server: {e}")
52
+ raise
53
+
54
+
55
+ # Export all tools for backwards compatibility
56
+ # This allows tests and existing code to import from server.py
57
+ from .tools.accounts import add_account, list_accounts, remove_account, test_account
58
+ from .tools.bulk import (
59
+ bulk_create_events,
60
+ bulk_create_journals,
61
+ bulk_create_tasks,
62
+ bulk_delete_events,
63
+ bulk_delete_journals,
64
+ bulk_delete_tasks,
65
+ )
66
+ from .tools.calendars import create_calendar, delete_calendar, list_calendars
67
+ from .tools.events import (
68
+ create_event,
69
+ create_recurring_event,
70
+ delete_event,
71
+ get_events_range,
72
+ search_events,
73
+ update_event,
74
+ )
75
+ from .tools.journals import (
76
+ create_journal,
77
+ delete_journal,
78
+ list_journals,
79
+ update_journal,
80
+ )
81
+ from .tools.tasks import create_task, delete_task, list_tasks, update_task
82
+
83
+ __all__ = [
84
+ # Account tools
85
+ "add_account",
86
+ "list_accounts",
87
+ "remove_account",
88
+ "test_account",
89
+ # Calendar tools
90
+ "list_calendars",
91
+ "create_calendar",
92
+ "delete_calendar",
93
+ # Event tools
94
+ "create_event",
95
+ "get_events_range",
96
+ "delete_event",
97
+ "update_event",
98
+ "create_recurring_event",
99
+ "search_events",
100
+ # Task tools
101
+ "create_task",
102
+ "list_tasks",
103
+ "update_task",
104
+ "delete_task",
105
+ # Journal tools
106
+ "create_journal",
107
+ "list_journals",
108
+ "update_journal",
109
+ "delete_journal",
110
+ # Bulk tools
111
+ "bulk_create_events",
112
+ "bulk_delete_events",
113
+ "bulk_create_tasks",
114
+ "bulk_delete_tasks",
115
+ "bulk_create_journals",
116
+ "bulk_delete_journals",
117
+ ]
118
+
119
+ # Main entry point for running the server
120
+ if __name__ == "__main__":
121
+ mcp.run()