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
@@ -0,0 +1,5 @@
1
+ """
2
+ Chronos MCP - Advanced CalDAV Management Server
3
+ """
4
+
5
+ __version__ = "0.1.2"
@@ -0,0 +1,9 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Main entry point for Chronos MCP
4
+ """
5
+
6
+ from .server import mcp
7
+
8
+ if __name__ == "__main__":
9
+ mcp.run()
@@ -0,0 +1,410 @@
1
+ """
2
+ Account management for Chronos MCP
3
+ """
4
+
5
+ import threading
6
+ import time
7
+ import uuid
8
+ from dataclasses import dataclass
9
+ from enum import Enum
10
+ from typing import Any, Dict, Optional
11
+
12
+ import caldav
13
+ from caldav import DAVClient, Principal
14
+
15
+ from .config import ConfigManager
16
+ from .credentials import get_credential_manager
17
+ from .exceptions import (
18
+ AccountAuthenticationError,
19
+ AccountConnectionError,
20
+ AccountNotFoundError,
21
+ ChronosError,
22
+ ErrorHandler,
23
+ ErrorSanitizer,
24
+ )
25
+ from .logging_config import setup_logging
26
+ from .models import AccountStatus
27
+
28
+ logger = setup_logging()
29
+
30
+
31
+ class CircuitBreakerState(Enum):
32
+ """Circuit breaker states"""
33
+
34
+ CLOSED = "closed"
35
+ OPEN = "open"
36
+ HALF_OPEN = "half_open"
37
+
38
+
39
+ @dataclass
40
+ class CircuitBreaker:
41
+ """Circuit breaker for connection failures"""
42
+
43
+ failure_count: int = 0
44
+ failure_threshold: int = 5
45
+ recovery_timeout: int = 60 # seconds
46
+ last_failure_time: float = 0
47
+ state: CircuitBreakerState = CircuitBreakerState.CLOSED
48
+
49
+ def should_allow_request(self) -> bool:
50
+ """Check if request should be allowed through circuit breaker"""
51
+ if self.state == CircuitBreakerState.CLOSED:
52
+ return True
53
+ elif self.state == CircuitBreakerState.OPEN:
54
+ if time.time() - self.last_failure_time >= self.recovery_timeout:
55
+ self.state = CircuitBreakerState.HALF_OPEN
56
+ return True
57
+ return False
58
+ else: # HALF_OPEN
59
+ return True
60
+
61
+ def record_success(self):
62
+ """Record successful operation"""
63
+ self.failure_count = 0
64
+ self.state = CircuitBreakerState.CLOSED
65
+
66
+ def record_failure(self):
67
+ """Record failed operation"""
68
+ self.failure_count += 1
69
+ self.last_failure_time = time.time()
70
+
71
+ if self.failure_count >= self.failure_threshold:
72
+ self.state = CircuitBreakerState.OPEN
73
+
74
+
75
+ @dataclass
76
+ class ConnectionHealth:
77
+ """Track connection health metrics"""
78
+
79
+ total_attempts: int = 0
80
+ successful_connections: int = 0
81
+ failed_connections: int = 0
82
+ last_success_time: float = 0
83
+ last_failure_time: float = 0
84
+
85
+ @property
86
+ def success_rate(self) -> float:
87
+ if self.total_attempts == 0:
88
+ return 1.0
89
+ return self.successful_connections / self.total_attempts
90
+
91
+
92
+ class AccountManager:
93
+ """Manage CalDAV account connections with lifecycle management"""
94
+
95
+ def __init__(self, config_manager: ConfigManager):
96
+ self.config = config_manager
97
+ self.connections: Dict[str, DAVClient] = {}
98
+ self.principals: Dict[str, Principal] = {}
99
+ self._connection_locks: Dict[str, threading.Lock] = {}
100
+ self._connection_timestamps: Dict[str, float] = {}
101
+ self._connection_ttl_minutes: int = 30 # Connection TTL in minutes
102
+
103
+ # Connection pool limits and health tracking
104
+ self._max_connections_per_account: int = 3
105
+ self._connection_timeout: int = 30 # Connection timeout in seconds
106
+ self._max_retries: int = 3
107
+ self._base_retry_delay: float = 1.0 # Base delay for exponential backoff
108
+
109
+ # Circuit breaker and health tracking
110
+ self._circuit_breakers: Dict[str, CircuitBreaker] = {}
111
+ self._connection_health: Dict[str, ConnectionHealth] = {}
112
+
113
+ def connect_account(self, alias: str, request_id: Optional[str] = None) -> bool:
114
+ """Connect to a CalDAV account with circuit breaker and retry logic"""
115
+ request_id = request_id or str(uuid.uuid4())
116
+
117
+ account = self.config.get_account(alias)
118
+ if not account:
119
+ raise AccountNotFoundError(alias, request_id=request_id)
120
+
121
+ # Check connection pool limits
122
+ if (
123
+ alias in self.connections
124
+ and len([k for k in self.connections.keys() if k == alias])
125
+ >= self._max_connections_per_account
126
+ ):
127
+ logger.warning(f"Connection pool limit reached for account '{alias}'")
128
+ # Clean up stale connections first
129
+ self._cleanup_stale_connection(alias)
130
+
131
+ # Initialize circuit breaker and health tracking if needed
132
+ if alias not in self._circuit_breakers:
133
+ self._circuit_breakers[alias] = CircuitBreaker()
134
+ if alias not in self._connection_health:
135
+ self._connection_health[alias] = ConnectionHealth()
136
+
137
+ circuit_breaker = self._circuit_breakers[alias]
138
+ health = self._connection_health[alias]
139
+
140
+ # Check circuit breaker
141
+ if not circuit_breaker.should_allow_request():
142
+ health.total_attempts += 1
143
+ health.failed_connections += 1
144
+ logger.error(
145
+ f"Circuit breaker OPEN for account '{alias}' - rejecting connection attempt",
146
+ extra={"request_id": request_id},
147
+ )
148
+ raise AccountConnectionError(
149
+ alias,
150
+ original_error=Exception("Circuit breaker is OPEN"),
151
+ request_id=request_id,
152
+ )
153
+
154
+ # Get password from keyring or fallback to config
155
+ credential_manager = get_credential_manager()
156
+ password = credential_manager.get_password(
157
+ alias, fallback_password=account.password
158
+ )
159
+
160
+ if not password:
161
+ raise AccountAuthenticationError(alias, request_id=request_id)
162
+
163
+ # Retry logic with exponential backoff
164
+ last_exception = None
165
+ for attempt in range(self._max_retries):
166
+ health.total_attempts += 1
167
+
168
+ try:
169
+ client = DAVClient(
170
+ url=str(account.url),
171
+ username=account.username,
172
+ password=password,
173
+ timeout=self._connection_timeout,
174
+ )
175
+
176
+ # Test connection by getting principal with timeout
177
+ principal = client.principal()
178
+
179
+ # Store connection with timestamp
180
+ self.connections[alias] = client
181
+ self.principals[alias] = principal
182
+ self._connection_timestamps[alias] = time.time()
183
+
184
+ # Ensure lock exists for this connection
185
+ if alias not in self._connection_locks:
186
+ self._connection_locks[alias] = threading.Lock()
187
+
188
+ # Record success
189
+ circuit_breaker.record_success()
190
+ health.successful_connections += 1
191
+ health.last_success_time = time.time()
192
+
193
+ account.status = AccountStatus.CONNECTED
194
+ logger.info(
195
+ f"Successfully connected to account '{alias}' on attempt {attempt + 1}",
196
+ extra={"request_id": request_id},
197
+ )
198
+ return True
199
+
200
+ except caldav.lib.error.AuthorizationError as e:
201
+ last_exception = e
202
+ circuit_breaker.record_failure()
203
+ health.failed_connections += 1
204
+ health.last_failure_time = time.time()
205
+
206
+ account.status = AccountStatus.ERROR
207
+ logger.error(
208
+ f"Authentication failed for '{alias}' on attempt {attempt + 1}: {e}",
209
+ extra={"request_id": request_id},
210
+ )
211
+ # Don't retry auth errors
212
+ raise AccountAuthenticationError(alias, request_id=request_id)
213
+
214
+ except Exception as e:
215
+ last_exception = e
216
+ logger.warning(
217
+ f"Connection attempt {attempt + 1} failed for '{alias}': {e}",
218
+ extra={"request_id": request_id},
219
+ )
220
+
221
+ if attempt < self._max_retries - 1:
222
+ delay = self._base_retry_delay * (2**attempt)
223
+ logger.debug(f"Retrying in {delay} seconds...")
224
+ time.sleep(delay)
225
+ else:
226
+ # All retries exhausted
227
+ circuit_breaker.record_failure()
228
+ health.failed_connections += 1
229
+ health.last_failure_time = time.time()
230
+
231
+ account.status = AccountStatus.ERROR
232
+ logger.error(
233
+ f"All {self._max_retries} connection attempts failed for '{alias}'",
234
+ extra={"request_id": request_id},
235
+ )
236
+ raise AccountConnectionError(
237
+ alias, original_error=last_exception, request_id=request_id
238
+ )
239
+
240
+ # Should never reach here, but just in case
241
+ raise AccountConnectionError(
242
+ alias, original_error=last_exception, request_id=request_id
243
+ )
244
+
245
+ def disconnect_account(self, alias: str):
246
+ """Disconnect from an account and clean up resources
247
+
248
+ Thread-safety: This method MUST be called while holding self._connection_locks[alias].
249
+ All callers (get_connection, get_principal) acquire lock before calling this method.
250
+ """
251
+ if alias in self.connections:
252
+ del self.connections[alias]
253
+ if alias in self.principals:
254
+ del self.principals[alias]
255
+ if alias in self._connection_timestamps:
256
+ del self._connection_timestamps[alias]
257
+ # Keep lock for reuse - don't delete self._connection_locks[alias]
258
+ # Reusing locks avoids race where Thread A deletes lock while Thread B tries to acquire it
259
+ # Note: Keep circuit breaker and health data for future connections
260
+
261
+ account = self.config.get_account(alias)
262
+ if account:
263
+ account.status = AccountStatus.DISCONNECTED
264
+
265
+ logger.debug(f"Disconnected and cleaned up resources for account '{alias}'")
266
+
267
+ def _cleanup_stale_connection(self, alias: str):
268
+ """Clean up a specific stale connection"""
269
+ if alias in self._connection_timestamps:
270
+ age_minutes = (time.time() - self._connection_timestamps[alias]) / 60
271
+ if age_minutes > self._connection_ttl_minutes:
272
+ logger.debug(
273
+ f"Cleaning up stale connection for '{alias}' (age: {age_minutes:.1f} min)"
274
+ )
275
+ self.disconnect_account(alias)
276
+ return True
277
+ return False
278
+
279
+ def get_connection_health(self, alias: str) -> Optional[ConnectionHealth]:
280
+ """Get connection health metrics for an account"""
281
+ return self._connection_health.get(alias)
282
+
283
+ def get_circuit_breaker_status(self, alias: str) -> Optional[CircuitBreakerState]:
284
+ """Get circuit breaker status for an account"""
285
+ breaker = self._circuit_breakers.get(alias)
286
+ return breaker.state if breaker else None
287
+
288
+ def cleanup_stale_connections(self, max_age_minutes: Optional[int] = None):
289
+ """Remove connections older than max_age_minutes"""
290
+ max_age = max_age_minutes or self._connection_ttl_minutes
291
+ current_time = time.time()
292
+ stale_aliases = []
293
+
294
+ for alias, timestamp in self._connection_timestamps.items():
295
+ age_minutes = (current_time - timestamp) / 60
296
+ if age_minutes > max_age:
297
+ stale_aliases.append(alias)
298
+
299
+ for alias in stale_aliases:
300
+ age_minutes = (current_time - self._connection_timestamps[alias]) / 60
301
+ logger.debug(
302
+ f"Cleaning up stale connection for account '{alias}' (age: {age_minutes:.1f} minutes)"
303
+ )
304
+ self.disconnect_account(alias)
305
+
306
+ if stale_aliases:
307
+ logger.info(f"Cleaned up {len(stale_aliases)} stale connections")
308
+
309
+ def _is_connection_stale(self, alias: str) -> bool:
310
+ """Check if a connection is stale"""
311
+ if alias not in self._connection_timestamps:
312
+ return True
313
+
314
+ age_minutes = (time.time() - self._connection_timestamps[alias]) / 60
315
+ return age_minutes > self._connection_ttl_minutes
316
+
317
+ @ErrorHandler.safe_operation(logger, default_return=None)
318
+ def get_connection(self, alias: Optional[str] = None) -> Optional[DAVClient]:
319
+ """Get connection for an account - internal utility method
320
+
321
+ Thread-safe connection management with proper TOCTOU prevention.
322
+ Staleness check MUST happen inside lock to prevent race conditions.
323
+ """
324
+ if not alias:
325
+ alias = self.config.config.default_account
326
+
327
+ if not alias:
328
+ return None
329
+
330
+ # Ensure lock exists before checking staleness
331
+ if alias not in self._connection_locks:
332
+ self._connection_locks[alias] = threading.Lock()
333
+
334
+ with self._connection_locks[alias]:
335
+ # Check staleness INSIDE lock to prevent TOCTOU race
336
+ # Race scenario without this: Thread A checks stale=True outside lock,
337
+ # Thread B connects, Thread A disconnects fresh connection
338
+ if alias not in self.connections or self._is_connection_stale(alias):
339
+ # Clean up stale connection if it exists
340
+ if alias in self.connections:
341
+ logger.debug(f"Connection for '{alias}' is stale, reconnecting")
342
+ self.disconnect_account(alias)
343
+
344
+ # Create new connection
345
+ self.connect_account(alias)
346
+
347
+ return self.connections.get(alias)
348
+
349
+ @ErrorHandler.safe_operation(logger, default_return=None)
350
+ def get_principal(self, alias: Optional[str] = None) -> Optional[Principal]:
351
+ """Get principal for an account - internal utility method
352
+
353
+ Thread-safe principal access with proper TOCTOU prevention.
354
+ Staleness check MUST happen inside lock to prevent race conditions.
355
+ """
356
+ if not alias:
357
+ alias = self.config.config.default_account
358
+
359
+ if not alias:
360
+ return None
361
+
362
+ # Ensure lock exists before checking staleness
363
+ if alias not in self._connection_locks:
364
+ self._connection_locks[alias] = threading.Lock()
365
+
366
+ with self._connection_locks[alias]:
367
+ # Check staleness INSIDE lock to prevent TOCTOU race
368
+ # Same pattern as get_connection() for consistency
369
+ if alias not in self.principals or self._is_connection_stale(alias):
370
+ # Clean up stale connection if it exists
371
+ if alias in self.principals:
372
+ logger.debug(f"Principal for '{alias}' is stale, reconnecting")
373
+ self.disconnect_account(alias)
374
+
375
+ # Create new connection (also updates principals)
376
+ self.connect_account(alias)
377
+
378
+ return self.principals.get(alias)
379
+
380
+ def test_account(
381
+ self, alias: str, request_id: Optional[str] = None
382
+ ) -> Dict[str, Any]:
383
+ """Test account connectivity and return structured result"""
384
+ result = {"alias": alias, "connected": False, "calendars": 0, "error": None}
385
+
386
+ request_id = request_id or str(uuid.uuid4())
387
+
388
+ try:
389
+ if self.connect_account(alias, request_id=request_id):
390
+ principal = self.principals.get(alias)
391
+ if principal:
392
+ calendars = principal.calendars()
393
+ result["connected"] = True
394
+ result["calendars"] = len(calendars)
395
+ except ChronosError as e:
396
+ # Use sanitized error message for user response
397
+ result["error"] = ErrorSanitizer.get_user_friendly_message(e)
398
+ logger.error(f"Test account failed: {e}", extra={"request_id": request_id})
399
+ except Exception as e:
400
+ # Unexpected error - wrap and sanitize
401
+ wrapped_error = AccountConnectionError(
402
+ alias, original_error=e, request_id=request_id
403
+ )
404
+ result["error"] = ErrorSanitizer.get_user_friendly_message(wrapped_error)
405
+ logger.error(
406
+ f"Test account failed with unexpected error: {wrapped_error}",
407
+ extra={"request_id": request_id},
408
+ )
409
+
410
+ return result