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/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()
|