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,149 @@
1
+ """
2
+ Shared CalDAV utility functions for calendar operations.
3
+
4
+ This module provides reusable patterns for working with CalDAV calendars,
5
+ eliminating code duplication across events, tasks, and journals managers.
6
+ """
7
+
8
+ import logging
9
+ from typing import Any, Optional
10
+ from icalendar import Calendar as iCalendar
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+
15
+ def get_item_with_fallback(
16
+ calendar,
17
+ uid: str,
18
+ item_type: str,
19
+ request_id: Optional[str] = None,
20
+ ) -> Any:
21
+ """
22
+ Get CalDAV item by UID with automatic fallback to full search.
23
+
24
+ This function implements a two-method approach for finding CalDAV items:
25
+ 1. Method 1: Try the direct UID lookup method if available (fast)
26
+ 2. Method 2: Fallback to iterating all items and matching UID (slow but reliable)
27
+
28
+ This pattern is necessary because not all CalDAV servers implement the
29
+ direct UID lookup methods (event_by_uid, todo_by_uid, journal_by_uid).
30
+
31
+ Args:
32
+ calendar: CalDAV calendar object
33
+ uid: Item UID to find
34
+ item_type: Type of item - one of: "event", "task", "journal"
35
+ request_id: Optional request ID for logging context
36
+
37
+ Returns:
38
+ CalDAV item object (Event, Todo, or Journal)
39
+
40
+ Raises:
41
+ ValueError: If item not found by either method
42
+ Exception: Any exception from CalDAV operations
43
+
44
+ Example:
45
+ >>> calendar = account.calendar(calendar_uid)
46
+ >>> event = get_item_with_fallback(calendar, "event-123", "event")
47
+ >>> task = get_item_with_fallback(calendar, "task-456", "task")
48
+ """
49
+ # Map item types to their CalDAV methods and component names
50
+ type_config = {
51
+ "event": {
52
+ "by_uid_method": "event_by_uid",
53
+ "list_method": "events",
54
+ "fallback_method": None, # events don't have fallback
55
+ "component_name": "VEVENT",
56
+ },
57
+ "task": {
58
+ "by_uid_method": "event_by_uid", # CalDAV uses event_by_uid for tasks
59
+ "list_method": "todos",
60
+ "fallback_method": "events", # If todos() not available, use events()
61
+ "component_name": "VTODO",
62
+ },
63
+ "journal": {
64
+ "by_uid_method": "event_by_uid", # CalDAV uses event_by_uid for journals
65
+ "list_method": "journals",
66
+ "fallback_method": "events", # If journals() not available, use events()
67
+ "component_name": "VJOURNAL",
68
+ },
69
+ }
70
+
71
+ if item_type not in type_config:
72
+ raise ValueError(f"Invalid item_type: {item_type}. Must be 'event', 'task', or 'journal'")
73
+
74
+ config = type_config[item_type]
75
+ by_uid_method = config["by_uid_method"]
76
+ list_method = config["list_method"]
77
+ fallback_method = config["fallback_method"]
78
+ component_name = config["component_name"]
79
+
80
+ # Method 1: Try direct UID lookup if available
81
+ if hasattr(calendar, by_uid_method):
82
+ try:
83
+ item = getattr(calendar, by_uid_method)(uid)
84
+ logger.debug(
85
+ f"Found {item_type} '{uid}' using {by_uid_method}",
86
+ extra={"request_id": request_id},
87
+ )
88
+ return item
89
+ except Exception as e:
90
+ logger.warning(
91
+ f"{by_uid_method} failed for {item_type} '{uid}': {e}, trying fallback method",
92
+ extra={"request_id": request_id},
93
+ )
94
+
95
+ # Method 2: Fallback - iterate all items and match UID
96
+ try:
97
+ # Get the list of items
98
+ if hasattr(calendar, list_method):
99
+ items = getattr(calendar, list_method)()
100
+ elif fallback_method and hasattr(calendar, fallback_method):
101
+ # Use fallback method if primary not available (e.g., todos() not available)
102
+ logger.debug(
103
+ f"{list_method}() not available, using {fallback_method}() for {item_type}",
104
+ extra={"request_id": request_id},
105
+ )
106
+ items = getattr(calendar, fallback_method)()
107
+ else:
108
+ raise ValueError(
109
+ f"Calendar does not support {list_method}() or {fallback_method}()"
110
+ )
111
+
112
+ # Search through items
113
+ for item in items:
114
+ # Check if UID matches in the raw data (fast check)
115
+ # Handle both bytes (real CalDAV) and string (test mocks)
116
+ if isinstance(item.data, bytes):
117
+ uid_to_check = uid.encode('utf-8')
118
+ else:
119
+ uid_to_check = uid
120
+
121
+ if uid_to_check in item.data:
122
+ # Parse iCalendar to verify exact UID match
123
+ try:
124
+ ical = iCalendar.from_ical(item.data)
125
+ for component in ical.walk():
126
+ if component.name == component_name:
127
+ item_uid = str(component.get("uid", ""))
128
+ if item_uid == uid:
129
+ logger.debug(
130
+ f"Found {item_type} '{uid}' using fallback search",
131
+ extra={"request_id": request_id},
132
+ )
133
+ return item
134
+ except Exception as parse_error:
135
+ logger.warning(
136
+ f"Failed to parse {item_type} data: {parse_error}",
137
+ extra={"request_id": request_id},
138
+ )
139
+ continue
140
+
141
+ except Exception as e:
142
+ logger.warning(
143
+ f"Fallback search failed for {item_type} '{uid}': {e}",
144
+ extra={"request_id": request_id},
145
+ )
146
+ raise
147
+
148
+ # Item not found by either method
149
+ raise ValueError(f"{item_type.capitalize()} with UID '{uid}' not found")
@@ -0,0 +1,204 @@
1
+ """
2
+ Calendar operations for Chronos MCP
3
+ """
4
+
5
+ import uuid
6
+ from typing import List, Optional
7
+
8
+ import caldav
9
+ from caldav import Calendar as CalDAVCalendar
10
+
11
+ from .accounts import AccountManager
12
+ from .exceptions import (
13
+ AccountNotFoundError,
14
+ CalendarCreationError,
15
+ CalendarDeletionError,
16
+ CalendarNotFoundError,
17
+ ErrorHandler,
18
+ )
19
+ from .logging_config import setup_logging
20
+ from .models import Calendar
21
+
22
+ logger = setup_logging()
23
+
24
+
25
+ class CalendarManager:
26
+ """Manage calendar operations"""
27
+
28
+ def __init__(self, account_manager: AccountManager):
29
+ self.accounts = account_manager
30
+
31
+ def list_calendars(
32
+ self, account_alias: Optional[str] = None, request_id: Optional[str] = None
33
+ ) -> List[Calendar]:
34
+ """List all calendars for an account - raises exceptions on failure"""
35
+ request_id = request_id or str(uuid.uuid4())
36
+
37
+ principal = self.accounts.get_principal(account_alias)
38
+ if not principal:
39
+ raise AccountNotFoundError(
40
+ account_alias
41
+ or self.accounts.config.config.default_account
42
+ or "default",
43
+ request_id=request_id,
44
+ )
45
+
46
+ calendars = []
47
+ try:
48
+ for cal in principal.calendars():
49
+ # Extract calendar properties
50
+ cal_info = Calendar(
51
+ uid=(
52
+ str(cal.url).split("/")[-2]
53
+ if str(cal.url).endswith("/")
54
+ else str(cal.url).split("/")[-1]
55
+ ),
56
+ name=cal.name or "Unnamed Calendar",
57
+ description=None, # Will need to fetch from properties
58
+ color=None, # Will need to fetch from properties
59
+ account_alias=account_alias
60
+ or self.accounts.config.config.default_account,
61
+ url=str(cal.url),
62
+ read_only=False, # Will need to check permissions
63
+ )
64
+ calendars.append(cal_info)
65
+
66
+ except Exception as e:
67
+ logger.error(f"Error listing calendars: {e}")
68
+
69
+ return calendars
70
+
71
+ def create_calendar(
72
+ self,
73
+ name: str,
74
+ description: Optional[str] = None,
75
+ color: Optional[str] = None,
76
+ account_alias: Optional[str] = None,
77
+ request_id: Optional[str] = None,
78
+ ) -> Optional[Calendar]:
79
+ """Create a new calendar - raises exceptions on failure"""
80
+ request_id = request_id or str(uuid.uuid4())
81
+
82
+ principal = self.accounts.get_principal(account_alias)
83
+ if not principal:
84
+ raise AccountNotFoundError(
85
+ account_alias
86
+ or self.accounts.config.config.default_account
87
+ or "default",
88
+ request_id=request_id,
89
+ )
90
+
91
+ try:
92
+ cal_id = name.lower().replace(" ", "_")
93
+ cal = principal.make_calendar(name=name, cal_id=cal_id)
94
+
95
+ # Note: description and color properties would need CalDAV server support
96
+ # for setting calendar properties beyond name
97
+
98
+ return Calendar(
99
+ uid=cal_id,
100
+ name=name,
101
+ description=description,
102
+ color=color,
103
+ account_alias=account_alias
104
+ or self.accounts.config.config.default_account,
105
+ url=str(cal.url),
106
+ read_only=False,
107
+ )
108
+
109
+ except caldav.lib.error.AuthorizationError as e:
110
+ logger.error(
111
+ f"Authorization error creating calendar '{name}': {e}",
112
+ extra={"request_id": request_id},
113
+ )
114
+ raise CalendarCreationError(
115
+ name, "Authorization failed", request_id=request_id
116
+ )
117
+ except Exception as e:
118
+ logger.error(
119
+ f"Error creating calendar '{name}': {e}",
120
+ extra={"request_id": request_id},
121
+ )
122
+ raise CalendarCreationError(name, str(e), request_id=request_id)
123
+
124
+ def delete_calendar(
125
+ self,
126
+ calendar_uid: str,
127
+ account_alias: Optional[str] = None,
128
+ request_id: Optional[str] = None,
129
+ ) -> bool:
130
+ """Delete a calendar - raises exceptions on failure"""
131
+ request_id = request_id or str(uuid.uuid4())
132
+
133
+ principal = self.accounts.get_principal(account_alias)
134
+ if not principal:
135
+ raise AccountNotFoundError(
136
+ account_alias
137
+ or self.accounts.config.config.default_account
138
+ or "default",
139
+ request_id=request_id,
140
+ )
141
+
142
+ try:
143
+ # Find calendar by UID
144
+ for cal in principal.calendars():
145
+ cal_id = (
146
+ str(cal.url).split("/")[-2]
147
+ if str(cal.url).endswith("/")
148
+ else str(cal.url).split("/")[-1]
149
+ )
150
+ if cal_id == calendar_uid:
151
+ cal.delete()
152
+ logger.info(
153
+ f"Deleted calendar '{calendar_uid}'",
154
+ extra={"request_id": request_id},
155
+ )
156
+ return True
157
+
158
+ # Calendar not found
159
+ raise CalendarNotFoundError(
160
+ calendar_uid, account_alias, request_id=request_id
161
+ )
162
+
163
+ except CalendarNotFoundError:
164
+ raise # Re-raise our own exception
165
+ except caldav.lib.error.AuthorizationError as e:
166
+ logger.error(
167
+ f"Authorization error deleting calendar '{calendar_uid}': {e}",
168
+ extra={"request_id": request_id},
169
+ )
170
+ raise CalendarDeletionError(
171
+ calendar_uid, "Authorization failed", request_id=request_id
172
+ )
173
+ except Exception as e:
174
+ logger.error(
175
+ f"Error deleting calendar '{calendar_uid}': {e}",
176
+ extra={"request_id": request_id},
177
+ )
178
+ raise CalendarDeletionError(calendar_uid, str(e), request_id=request_id)
179
+
180
+ @ErrorHandler.safe_operation(logger, default_return=None)
181
+ def get_calendar(
182
+ self,
183
+ calendar_uid: str,
184
+ account_alias: Optional[str] = None,
185
+ request_id: Optional[str] = None,
186
+ ) -> Optional[CalDAVCalendar]:
187
+ """Get CalDAV calendar object by UID - internal utility method"""
188
+ principal = self.accounts.get_principal(account_alias)
189
+ if not principal:
190
+ return None
191
+
192
+ try:
193
+ for cal in principal.calendars():
194
+ cal_id = (
195
+ str(cal.url).split("/")[-2]
196
+ if str(cal.url).endswith("/")
197
+ else str(cal.url).split("/")[-1]
198
+ )
199
+ if cal_id == calendar_uid:
200
+ return cal
201
+ except Exception as e:
202
+ logger.error(f"Error getting calendar: {e}")
203
+
204
+ return None
chronos_mcp/config.py ADDED
@@ -0,0 +1,187 @@
1
+ """
2
+ Configuration management for Chronos MCP
3
+ """
4
+
5
+ import json
6
+ import os
7
+ from pathlib import Path
8
+ from typing import Dict, Optional
9
+
10
+ from pydantic import BaseModel, Field
11
+
12
+ from .credentials import get_credential_manager
13
+ from .logging_config import setup_logging
14
+ from .models import Account
15
+
16
+ logger = setup_logging()
17
+
18
+
19
+ class ChronosConfig(BaseModel):
20
+ """Main configuration"""
21
+
22
+ accounts: Dict[str, Account] = Field(
23
+ default_factory=dict, description="Configured accounts"
24
+ )
25
+ default_account: Optional[str] = Field(None, description="Default account alias")
26
+
27
+
28
+ class ConfigManager:
29
+ """Manage Chronos configuration"""
30
+
31
+ def __init__(self):
32
+ self.config_dir = Path.home() / ".chronos"
33
+ self.config_file = self.config_dir / "accounts.json"
34
+ self.config: ChronosConfig = ChronosConfig()
35
+ self._load_config()
36
+
37
+ def _load_config(self):
38
+ """Load configuration from file and environment"""
39
+ # First, try to load from config file
40
+ if self.config_file.exists():
41
+ try:
42
+ with open(self.config_file, "r") as f:
43
+ data = json.load(f)
44
+ # Convert account dicts to Account objects
45
+ accounts = {}
46
+ for alias, acc_data in data.get("accounts", {}).items():
47
+ acc_data["alias"] = alias
48
+ if "url" in acc_data and isinstance(acc_data["url"], str):
49
+ accounts[alias] = Account(**acc_data)
50
+
51
+ self.config = ChronosConfig(
52
+ accounts=accounts, default_account=data.get("default_account")
53
+ )
54
+ logger.info(f"Loaded {len(accounts)} accounts from config file")
55
+ except Exception as e:
56
+ logger.error(f"Error loading config file: {e}")
57
+
58
+ env_url = os.getenv("CALDAV_BASE_URL")
59
+ env_username = os.getenv("CALDAV_USERNAME")
60
+ env_password = os.getenv("CALDAV_PASSWORD")
61
+
62
+ # Validate environment variables before use (defense-in-depth)
63
+ if env_url and env_username:
64
+ from .validation import InputValidator
65
+
66
+ try:
67
+ # Allow local URLs for development environments
68
+ env_url = InputValidator.validate_url(
69
+ env_url, allow_private_ips=True, field_name="CALDAV_BASE_URL"
70
+ )
71
+ env_username = InputValidator.validate_text_field(
72
+ env_username, "CALDAV_USERNAME", required=True
73
+ )
74
+ if env_password:
75
+ env_password = InputValidator.validate_text_field(
76
+ env_password, "CALDAV_PASSWORD", required=True
77
+ )
78
+ except Exception as e:
79
+ logger.error(f"Invalid environment variable values: {e}")
80
+ return # Skip environment account creation if validation fails
81
+
82
+ if env_url and env_username:
83
+ env_account = Account(
84
+ alias="default",
85
+ url=env_url,
86
+ username=env_username,
87
+ password=env_password,
88
+ display_name="Default Account (from environment)",
89
+ )
90
+
91
+ if "default" not in self.config.accounts:
92
+ # Store password in keyring if available
93
+ if env_password:
94
+ credential_manager = get_credential_manager()
95
+ if credential_manager.keyring_available:
96
+ if credential_manager.set_password("default", env_password):
97
+ logger.info("Environment password stored in keyring")
98
+ # Don't include password in account object if stored in keyring
99
+ env_account.password = None
100
+
101
+ self.config.accounts["default"] = env_account
102
+ if not self.config.default_account:
103
+ self.config.default_account = "default"
104
+ logger.info("Added default account from environment variables")
105
+
106
+ def save_config(self):
107
+ """Save configuration to file"""
108
+ self.config_dir.mkdir(exist_ok=True)
109
+
110
+ credential_manager = get_credential_manager()
111
+
112
+ data = {"accounts": {}, "default_account": self.config.default_account}
113
+
114
+ for alias, acc in self.config.accounts.items():
115
+ account_data = {
116
+ "url": str(acc.url),
117
+ "username": acc.username,
118
+ "display_name": acc.display_name,
119
+ }
120
+
121
+ # Only save password to config if keyring is not available
122
+ if not credential_manager.keyring_available and acc.password:
123
+ account_data["password"] = acc.password
124
+ logger.warning(
125
+ f"Saving password for '{alias}' to config file (keyring not available)"
126
+ )
127
+
128
+ data["accounts"][alias] = account_data
129
+
130
+ with open(self.config_file, "w") as f:
131
+ json.dump(data, f, indent=2)
132
+ logger.info("Configuration saved")
133
+
134
+ def add_account(self, account: Account):
135
+ """Add a new account
136
+
137
+ Args:
138
+ account: The Account object to add
139
+
140
+ Raises:
141
+ AccountAlreadyExistsError: If an account with the same alias already exists
142
+ """
143
+ if account.alias in self.config.accounts:
144
+ from .exceptions import AccountAlreadyExistsError
145
+
146
+ raise AccountAlreadyExistsError(account.alias)
147
+
148
+ # Store password in keyring if available
149
+ if account.password:
150
+ credential_manager = get_credential_manager()
151
+ if credential_manager.keyring_available:
152
+ if credential_manager.set_password(account.alias, account.password):
153
+ logger.info(f"Password for '{account.alias}' stored in keyring")
154
+ else:
155
+ logger.warning(
156
+ f"Failed to store password in keyring for '{account.alias}'"
157
+ )
158
+
159
+ self.config.accounts[account.alias] = account
160
+ if not self.config.default_account:
161
+ self.config.default_account = account.alias
162
+ self.save_config()
163
+
164
+ def remove_account(self, alias: str):
165
+ """Remove an account"""
166
+ if alias in self.config.accounts:
167
+ # Remove password from keyring if stored there
168
+ credential_manager = get_credential_manager()
169
+ if credential_manager.delete_password(alias):
170
+ logger.info(f"Password removed from keyring for account: {alias}")
171
+
172
+ del self.config.accounts[alias]
173
+ if self.config.default_account == alias:
174
+ self.config.default_account = next(iter(self.config.accounts), None)
175
+ self.save_config()
176
+
177
+ def get_account(self, alias: Optional[str] = None) -> Optional[Account]:
178
+ """Get an account by alias or return default"""
179
+ if alias:
180
+ return self.config.accounts.get(alias)
181
+ elif self.config.default_account:
182
+ return self.config.accounts.get(self.config.default_account)
183
+ return None
184
+
185
+ def list_accounts(self) -> Dict[str, Account]:
186
+ """List all configured accounts"""
187
+ return self.config.accounts