workspace-mcp 0.2.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.
core/utils.py ADDED
@@ -0,0 +1,162 @@
1
+ import io
2
+ import logging
3
+ import os
4
+ import tempfile
5
+ import zipfile, xml.etree.ElementTree as ET
6
+
7
+ from typing import List, Optional
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+ def check_credentials_directory_permissions(credentials_dir: str = ".credentials") -> None:
12
+ """
13
+ Check if the service has appropriate permissions to create and write to the .credentials directory.
14
+
15
+ Args:
16
+ credentials_dir: Path to the credentials directory (default: ".credentials")
17
+
18
+ Raises:
19
+ PermissionError: If the service lacks necessary permissions
20
+ OSError: If there are other file system issues
21
+ """
22
+ try:
23
+ # Check if directory exists
24
+ if os.path.exists(credentials_dir):
25
+ # Directory exists, check if we can write to it
26
+ test_file = os.path.join(credentials_dir, ".permission_test")
27
+ try:
28
+ with open(test_file, 'w') as f:
29
+ f.write("test")
30
+ os.remove(test_file)
31
+ logger.info(f"Credentials directory permissions check passed: {os.path.abspath(credentials_dir)}")
32
+ except (PermissionError, OSError) as e:
33
+ raise PermissionError(f"Cannot write to existing credentials directory '{os.path.abspath(credentials_dir)}': {e}")
34
+ else:
35
+ # Directory doesn't exist, check if we can create it
36
+ parent_dir = os.path.dirname(os.path.abspath(credentials_dir)) or "."
37
+ if not os.access(parent_dir, os.W_OK):
38
+ raise PermissionError(f"Cannot create credentials directory '{os.path.abspath(credentials_dir)}': insufficient permissions in parent directory '{parent_dir}'")
39
+
40
+ # Test creating the directory
41
+ try:
42
+ os.makedirs(credentials_dir, exist_ok=True)
43
+ # Test writing to the new directory
44
+ test_file = os.path.join(credentials_dir, ".permission_test")
45
+ with open(test_file, 'w') as f:
46
+ f.write("test")
47
+ os.remove(test_file)
48
+ logger.info(f"Created credentials directory with proper permissions: {os.path.abspath(credentials_dir)}")
49
+ except (PermissionError, OSError) as e:
50
+ # Clean up if we created the directory but can't write to it
51
+ try:
52
+ if os.path.exists(credentials_dir):
53
+ os.rmdir(credentials_dir)
54
+ except:
55
+ pass
56
+ raise PermissionError(f"Cannot create or write to credentials directory '{os.path.abspath(credentials_dir)}': {e}")
57
+
58
+ except PermissionError:
59
+ raise
60
+ except Exception as e:
61
+ raise OSError(f"Unexpected error checking credentials directory permissions: {e}")
62
+
63
+ def extract_office_xml_text(file_bytes: bytes, mime_type: str) -> Optional[str]:
64
+ """
65
+ Very light-weight XML scraper for Word, Excel, PowerPoint files.
66
+ Returns plain-text if something readable is found, else None.
67
+ No external deps – just std-lib zipfile + ElementTree.
68
+ """
69
+ shared_strings: List[str] = []
70
+ ns_excel_main = 'http://schemas.openxmlformats.org/spreadsheetml/2006/main'
71
+
72
+ try:
73
+ with zipfile.ZipFile(io.BytesIO(file_bytes)) as zf:
74
+ targets: List[str] = []
75
+ # Map MIME → iterable of XML files to inspect
76
+ if mime_type == "application/vnd.openxmlformats-officedocument.wordprocessingml.document":
77
+ targets = ["word/document.xml"]
78
+ elif mime_type == "application/vnd.openxmlformats-officedocument.presentationml.presentation":
79
+ targets = [n for n in zf.namelist() if n.startswith("ppt/slides/slide")]
80
+ elif mime_type == "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet":
81
+ targets = [n for n in zf.namelist() if n.startswith("xl/worksheets/sheet") and "drawing" not in n]
82
+ # Attempt to parse sharedStrings.xml for Excel files
83
+ try:
84
+ shared_strings_xml = zf.read("xl/sharedStrings.xml")
85
+ shared_strings_root = ET.fromstring(shared_strings_xml)
86
+ for si_element in shared_strings_root.findall(f"{{{ns_excel_main}}}si"):
87
+ text_parts = []
88
+ # Find all <t> elements, simple or within <r> runs, and concatenate their text
89
+ for t_element in si_element.findall(f".//{{{ns_excel_main}}}t"):
90
+ if t_element.text:
91
+ text_parts.append(t_element.text)
92
+ shared_strings.append("".join(text_parts))
93
+ except KeyError:
94
+ logger.info("No sharedStrings.xml found in Excel file (this is optional).")
95
+ except ET.ParseError as e:
96
+ logger.error(f"Error parsing sharedStrings.xml: {e}")
97
+ except Exception as e: # Catch any other unexpected error during sharedStrings parsing
98
+ logger.error(f"Unexpected error processing sharedStrings.xml: {e}", exc_info=True)
99
+ else:
100
+ return None
101
+
102
+ pieces: List[str] = []
103
+ for member in targets:
104
+ try:
105
+ xml_content = zf.read(member)
106
+ xml_root = ET.fromstring(xml_content)
107
+ member_texts: List[str] = []
108
+
109
+ if mime_type == "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet":
110
+ for cell_element in xml_root.findall(f".//{{{ns_excel_main}}}c"): # Find all <c> elements
111
+ value_element = cell_element.find(f"{{{ns_excel_main}}}v") # Find <v> under <c>
112
+
113
+ # Skip if cell has no value element or value element has no text
114
+ if value_element is None or value_element.text is None:
115
+ continue
116
+
117
+ cell_type = cell_element.get('t')
118
+ if cell_type == 's': # Shared string
119
+ try:
120
+ ss_idx = int(value_element.text)
121
+ if 0 <= ss_idx < len(shared_strings):
122
+ member_texts.append(shared_strings[ss_idx])
123
+ else:
124
+ logger.warning(f"Invalid shared string index {ss_idx} in {member}. Max index: {len(shared_strings)-1}")
125
+ except ValueError:
126
+ logger.warning(f"Non-integer shared string index: '{value_element.text}' in {member}.")
127
+ else: # Direct value (number, boolean, inline string if not 's')
128
+ member_texts.append(value_element.text)
129
+ else: # Word or PowerPoint
130
+ for elem in xml_root.iter():
131
+ # For Word: <w:t> where w is "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
132
+ # For PowerPoint: <a:t> where a is "http://schemas.openxmlformats.org/drawingml/2006/main"
133
+ if elem.tag.endswith("}t") and elem.text: # Check for any namespaced tag ending with 't'
134
+ cleaned_text = elem.text.strip()
135
+ if cleaned_text: # Add only if there's non-whitespace text
136
+ member_texts.append(cleaned_text)
137
+
138
+ if member_texts:
139
+ pieces.append(" ".join(member_texts)) # Join texts from one member with spaces
140
+
141
+ except ET.ParseError as e:
142
+ logger.warning(f"Could not parse XML in member '{member}' for {mime_type} file: {e}")
143
+ except Exception as e:
144
+ logger.error(f"Error processing member '{member}' for {mime_type}: {e}", exc_info=True)
145
+ # continue processing other members
146
+
147
+ if not pieces: # If no text was extracted at all
148
+ return None
149
+
150
+ # Join content from different members (sheets/slides) with double newlines for separation
151
+ text = "\n\n".join(pieces).strip()
152
+ return text or None # Ensure None is returned if text is empty after strip
153
+
154
+ except zipfile.BadZipFile:
155
+ logger.warning(f"File is not a valid ZIP archive (mime_type: {mime_type}).")
156
+ return None
157
+ except ET.ParseError as e: # Catch parsing errors at the top level if zipfile itself is XML-like
158
+ logger.error(f"XML parsing error at a high level for {mime_type}: {e}")
159
+ return None
160
+ except Exception as e:
161
+ logger.error(f"Failed to extract office XML text for {mime_type}: {e}", exc_info=True)
162
+ return None
gcalendar/__init__.py ADDED
@@ -0,0 +1 @@
1
+ # Make the calendar directory a Python package
@@ -0,0 +1,496 @@
1
+ """
2
+ Google Calendar MCP Tools
3
+
4
+ This module provides MCP tools for interacting with Google Calendar API.
5
+ """
6
+
7
+ import datetime
8
+ import logging
9
+ import asyncio
10
+ import os
11
+ import sys
12
+ from typing import List, Optional, Dict, Any
13
+
14
+ from mcp import types
15
+ from googleapiclient.errors import HttpError
16
+
17
+ from auth.service_decorator import require_google_service
18
+
19
+ from core.server import server
20
+
21
+
22
+ # Configure module logger
23
+ logger = logging.getLogger(__name__)
24
+
25
+
26
+ # Helper function to ensure time strings for API calls are correctly formatted
27
+ def _correct_time_format_for_api(
28
+ time_str: Optional[str], param_name: str
29
+ ) -> Optional[str]:
30
+ if not time_str:
31
+ return None
32
+
33
+ # Log the incoming time string for debugging
34
+ logger.info(
35
+ f"_correct_time_format_for_api: Processing {param_name} with value '{time_str}'"
36
+ )
37
+
38
+ # Handle date-only format (YYYY-MM-DD)
39
+ if len(time_str) == 10 and time_str.count("-") == 2:
40
+ try:
41
+ # Validate it's a proper date
42
+ datetime.datetime.strptime(time_str, "%Y-%m-%d")
43
+ # For date-only, append T00:00:00Z to make it RFC3339 compliant
44
+ formatted = f"{time_str}T00:00:00Z"
45
+ logger.info(
46
+ f"Formatting date-only {param_name} '{time_str}' to RFC3339: '{formatted}'"
47
+ )
48
+ return formatted
49
+ except ValueError:
50
+ logger.warning(
51
+ f"{param_name} '{time_str}' looks like a date but is not valid YYYY-MM-DD. Using as is."
52
+ )
53
+ return time_str
54
+
55
+ # Specifically address YYYY-MM-DDTHH:MM:SS by appending 'Z'
56
+ if (
57
+ len(time_str) == 19
58
+ and time_str[10] == "T"
59
+ and time_str.count(":") == 2
60
+ and not (
61
+ time_str.endswith("Z") or ("+" in time_str[10:]) or ("-" in time_str[10:])
62
+ )
63
+ ):
64
+ try:
65
+ # Validate the format before appending 'Z'
66
+ datetime.datetime.strptime(time_str, "%Y-%m-%dT%H:%M:%S")
67
+ logger.info(
68
+ f"Formatting {param_name} '{time_str}' by appending 'Z' for UTC."
69
+ )
70
+ return time_str + "Z"
71
+ except ValueError:
72
+ logger.warning(
73
+ f"{param_name} '{time_str}' looks like it needs 'Z' but is not valid YYYY-MM-DDTHH:MM:SS. Using as is."
74
+ )
75
+ return time_str
76
+
77
+ # If it already has timezone info or doesn't match our patterns, return as is
78
+ logger.info(f"{param_name} '{time_str}' doesn't need formatting, using as is.")
79
+ return time_str
80
+
81
+
82
+ @server.tool()
83
+ @require_google_service("calendar", "calendar_read")
84
+ async def list_calendars(service, user_google_email: str) -> str:
85
+ """
86
+ Retrieves a list of calendars accessible to the authenticated user.
87
+
88
+ Args:
89
+ user_google_email (str): The user's Google email address. Required.
90
+
91
+ Returns:
92
+ str: A formatted list of the user's calendars (summary, ID, primary status).
93
+ """
94
+ logger.info(f"[list_calendars] Invoked. Email: '{user_google_email}'")
95
+
96
+ try:
97
+ calendar_list_response = await asyncio.to_thread(
98
+ service.calendarList().list().execute
99
+ )
100
+ items = calendar_list_response.get("items", [])
101
+ if not items:
102
+ return f"No calendars found for {user_google_email}."
103
+
104
+ calendars_summary_list = [
105
+ f"- \"{cal.get('summary', 'No Summary')}\"{' (Primary)' if cal.get('primary') else ''} (ID: {cal['id']})"
106
+ for cal in items
107
+ ]
108
+ text_output = (
109
+ f"Successfully listed {len(items)} calendars for {user_google_email}:\n"
110
+ + "\n".join(calendars_summary_list)
111
+ )
112
+ logger.info(f"Successfully listed {len(items)} calendars for {user_google_email}.")
113
+ return text_output
114
+ except HttpError as error:
115
+ message = f"API error listing calendars: {error}. You might need to re-authenticate. LLM: Try 'start_google_auth' with user's email and service_name='Google Calendar'."
116
+ logger.error(message, exc_info=True)
117
+ raise Exception(message)
118
+ except Exception as e:
119
+ message = f"Unexpected error listing calendars: {e}."
120
+ logger.exception(message)
121
+ raise Exception(message)
122
+
123
+
124
+ @server.tool()
125
+ @require_google_service("calendar", "calendar_read")
126
+ async def get_events(
127
+ service,
128
+ user_google_email: str,
129
+ calendar_id: str = "primary",
130
+ time_min: Optional[str] = None,
131
+ time_max: Optional[str] = None,
132
+ max_results: int = 25,
133
+ ) -> str:
134
+ """
135
+ Retrieves a list of events from a specified Google Calendar within a given time range.
136
+
137
+ Args:
138
+ user_google_email (str): The user's Google email address. Required.
139
+ calendar_id (str): The ID of the calendar to query. Use 'primary' for the user's primary calendar. Defaults to 'primary'. Calendar IDs can be obtained using `list_calendars`.
140
+ time_min (Optional[str]): The start of the time range (inclusive) in RFC3339 format (e.g., '2024-05-12T10:00:00Z' or '2024-05-12'). If omitted, defaults to the current time.
141
+ time_max (Optional[str]): The end of the time range (exclusive) in RFC3339 format. If omitted, events starting from `time_min` onwards are considered (up to `max_results`).
142
+ max_results (int): The maximum number of events to return. Defaults to 25.
143
+
144
+ Returns:
145
+ str: A formatted list of events (summary, start time, link) within the specified range.
146
+ """
147
+ try:
148
+ logger.info(
149
+ f"[get_events] Raw time parameters - time_min: '{time_min}', time_max: '{time_max}'"
150
+ )
151
+
152
+ # Ensure time_min and time_max are correctly formatted for the API
153
+ formatted_time_min = _correct_time_format_for_api(time_min, "time_min")
154
+ effective_time_min = formatted_time_min or (
155
+ datetime.datetime.utcnow().isoformat() + "Z"
156
+ )
157
+ if time_min is None:
158
+ logger.info(
159
+ f"time_min not provided, defaulting to current UTC time: {effective_time_min}"
160
+ )
161
+ else:
162
+ logger.info(
163
+ f"time_min processing: original='{time_min}', formatted='{formatted_time_min}', effective='{effective_time_min}'"
164
+ )
165
+
166
+ effective_time_max = _correct_time_format_for_api(time_max, "time_max")
167
+ if time_max:
168
+ logger.info(
169
+ f"time_max processing: original='{time_max}', formatted='{effective_time_max}'"
170
+ )
171
+
172
+ # Log the final API call parameters
173
+ logger.info(
174
+ f"[get_events] Final API parameters - calendarId: '{calendar_id}', timeMin: '{effective_time_min}', timeMax: '{effective_time_max}', maxResults: {max_results}"
175
+ )
176
+
177
+ events_result = await asyncio.to_thread(
178
+ service.events()
179
+ .list(
180
+ calendarId=calendar_id,
181
+ timeMin=effective_time_min,
182
+ timeMax=effective_time_max,
183
+ maxResults=max_results,
184
+ singleEvents=True,
185
+ orderBy="startTime",
186
+ )
187
+ .execute
188
+ )
189
+ items = events_result.get("items", [])
190
+ if not items:
191
+ return f"No events found in calendar '{calendar_id}' for {user_google_email} for the specified time range."
192
+
193
+ event_details_list = []
194
+ for item in items:
195
+ summary = item.get("summary", "No Title")
196
+ start = item["start"].get("dateTime", item["start"].get("date"))
197
+ link = item.get("htmlLink", "No Link")
198
+ event_id = item.get("id", "No ID")
199
+ # Include the event ID in the output so users can copy it for modify/delete operations
200
+ event_details_list.append(
201
+ f'- "{summary}" (Starts: {start}) ID: {event_id} | Link: {link}'
202
+ )
203
+
204
+ text_output = (
205
+ f"Successfully retrieved {len(items)} events from calendar '{calendar_id}' for {user_google_email}:\n"
206
+ + "\n".join(event_details_list)
207
+ )
208
+ logger.info(f"Successfully retrieved {len(items)} events for {user_google_email}.")
209
+ return text_output
210
+ except HttpError as error:
211
+ message = f"API error getting events: {error}. You might need to re-authenticate. LLM: Try 'start_google_auth' with user's email and service_name='Google Calendar'."
212
+ logger.error(message, exc_info=True)
213
+ raise Exception(message)
214
+ except Exception as e:
215
+ message = f"Unexpected error getting events: {e}"
216
+ logger.exception(message)
217
+ raise Exception(message)
218
+
219
+
220
+ @server.tool()
221
+ @require_google_service("calendar", "calendar_events")
222
+ async def create_event(
223
+ service,
224
+ user_google_email: str,
225
+ summary: str,
226
+ start_time: str,
227
+ end_time: str,
228
+ calendar_id: str = "primary",
229
+ description: Optional[str] = None,
230
+ location: Optional[str] = None,
231
+ attendees: Optional[List[str]] = None,
232
+ timezone: Optional[str] = None,
233
+ ) -> str:
234
+ """
235
+ Creates a new event.
236
+
237
+ Args:
238
+ user_google_email (str): The user's Google email address. Required.
239
+ summary (str): Event title.
240
+ start_time (str): Start time (RFC3339, e.g., "2023-10-27T10:00:00-07:00" or "2023-10-27" for all-day).
241
+ end_time (str): End time (RFC3339, e.g., "2023-10-27T11:00:00-07:00" or "2023-10-28" for all-day).
242
+ calendar_id (str): Calendar ID (default: 'primary').
243
+ description (Optional[str]): Event description.
244
+ location (Optional[str]): Event location.
245
+ attendees (Optional[List[str]]): Attendee email addresses.
246
+ timezone (Optional[str]): Timezone (e.g., "America/New_York").
247
+
248
+ Returns:
249
+ str: Confirmation message of the successful event creation with event link.
250
+ """
251
+ logger.info(
252
+ f"[create_event] Invoked. Email: '{user_google_email}', Summary: {summary}"
253
+ )
254
+
255
+ try:
256
+ event_body: Dict[str, Any] = {
257
+ "summary": summary,
258
+ "start": (
259
+ {"date": start_time}
260
+ if "T" not in start_time
261
+ else {"dateTime": start_time}
262
+ ),
263
+ "end": (
264
+ {"date": end_time} if "T" not in end_time else {"dateTime": end_time}
265
+ ),
266
+ }
267
+ if location:
268
+ event_body["location"] = location
269
+ if description:
270
+ event_body["description"] = description
271
+ if timezone:
272
+ if "dateTime" in event_body["start"]:
273
+ event_body["start"]["timeZone"] = timezone
274
+ if "dateTime" in event_body["end"]:
275
+ event_body["end"]["timeZone"] = timezone
276
+ if attendees:
277
+ event_body["attendees"] = [{"email": email} for email in attendees]
278
+
279
+ created_event = await asyncio.to_thread(
280
+ service.events().insert(calendarId=calendar_id, body=event_body).execute
281
+ )
282
+
283
+ link = created_event.get("htmlLink", "No link available")
284
+ confirmation_message = f"Successfully created event '{created_event.get('summary', summary)}' for {user_google_email}. Link: {link}"
285
+ logger.info(
286
+ f"Event created successfully for {user_google_email}. ID: {created_event.get('id')}, Link: {link}"
287
+ )
288
+ return confirmation_message
289
+ except HttpError as error:
290
+ message = f"API error creating event: {error}. You might need to re-authenticate. LLM: Try 'start_google_auth' with the user's email ({user_google_email}) and service_name='Google Calendar'."
291
+ logger.error(message, exc_info=True)
292
+ raise Exception(message)
293
+ except Exception as e:
294
+ message = f"Unexpected error creating event: {e}."
295
+ logger.exception(message)
296
+ raise Exception(message)
297
+
298
+
299
+ @server.tool()
300
+ @require_google_service("calendar", "calendar_events")
301
+ async def modify_event(
302
+ service,
303
+ user_google_email: str,
304
+ event_id: str,
305
+ calendar_id: str = "primary",
306
+ summary: Optional[str] = None,
307
+ start_time: Optional[str] = None,
308
+ end_time: Optional[str] = None,
309
+ description: Optional[str] = None,
310
+ location: Optional[str] = None,
311
+ attendees: Optional[List[str]] = None,
312
+ timezone: Optional[str] = None,
313
+ ) -> str:
314
+ """
315
+ Modifies an existing event.
316
+
317
+ Args:
318
+ user_google_email (str): The user's Google email address. Required.
319
+ event_id (str): The ID of the event to modify.
320
+ calendar_id (str): Calendar ID (default: 'primary').
321
+ summary (Optional[str]): New event title.
322
+ start_time (Optional[str]): New start time (RFC3339, e.g., "2023-10-27T10:00:00-07:00" or "2023-10-27" for all-day).
323
+ end_time (Optional[str]): New end time (RFC3339, e.g., "2023-10-27T11:00:00-07:00" or "2023-10-28" for all-day).
324
+ description (Optional[str]): New event description.
325
+ location (Optional[str]): New event location.
326
+ attendees (Optional[List[str]]): New attendee email addresses.
327
+ timezone (Optional[str]): New timezone (e.g., "America/New_York").
328
+
329
+ Returns:
330
+ str: Confirmation message of the successful event modification with event link.
331
+ """
332
+ logger.info(
333
+ f"[modify_event] Invoked. Email: '{user_google_email}', Event ID: {event_id}"
334
+ )
335
+
336
+ try:
337
+ # Build the event body with only the fields that are provided
338
+ event_body: Dict[str, Any] = {}
339
+ if summary is not None:
340
+ event_body["summary"] = summary
341
+ if start_time is not None:
342
+ event_body["start"] = (
343
+ {"date": start_time}
344
+ if "T" not in start_time
345
+ else {"dateTime": start_time}
346
+ )
347
+ if timezone is not None and "dateTime" in event_body["start"]:
348
+ event_body["start"]["timeZone"] = timezone
349
+ if end_time is not None:
350
+ event_body["end"] = (
351
+ {"date": end_time} if "T" not in end_time else {"dateTime": end_time}
352
+ )
353
+ if timezone is not None and "dateTime" in event_body["end"]:
354
+ event_body["end"]["timeZone"] = timezone
355
+ if description is not None:
356
+ event_body["description"] = description
357
+ if location is not None:
358
+ event_body["location"] = location
359
+ if attendees is not None:
360
+ event_body["attendees"] = [{"email": email} for email in attendees]
361
+ if (
362
+ timezone is not None
363
+ and "start" not in event_body
364
+ and "end" not in event_body
365
+ ):
366
+ # If timezone is provided but start/end times are not, we need to fetch the existing event
367
+ # to apply the timezone correctly. This is a simplification; a full implementation
368
+ # might handle this more robustly or require start/end with timezone.
369
+ # For now, we'll log a warning and skip applying timezone if start/end are missing.
370
+ logger.warning(
371
+ f"[modify_event] Timezone provided but start_time and end_time are missing. Timezone will not be applied unless start/end times are also provided."
372
+ )
373
+
374
+ if not event_body:
375
+ message = "No fields provided to modify the event."
376
+ logger.warning(f"[modify_event] {message}")
377
+ raise Exception(message)
378
+
379
+ # Log the event ID for debugging
380
+ logger.info(
381
+ f"[modify_event] Attempting to update event with ID: '{event_id}' in calendar '{calendar_id}'"
382
+ )
383
+
384
+ # Try to get the event first to verify it exists
385
+ try:
386
+ await asyncio.to_thread(
387
+ service.events().get(calendarId=calendar_id, eventId=event_id).execute
388
+ )
389
+ logger.info(
390
+ f"[modify_event] Successfully verified event exists before update"
391
+ )
392
+ except HttpError as get_error:
393
+ if get_error.resp.status == 404:
394
+ logger.error(
395
+ f"[modify_event] Event not found during pre-update verification: {get_error}"
396
+ )
397
+ message = f"Event not found during verification. The event with ID '{event_id}' could not be found in calendar '{calendar_id}'. This may be due to incorrect ID format or the event no longer exists."
398
+ raise Exception(message)
399
+ else:
400
+ logger.warning(
401
+ f"[modify_event] Error during pre-update verification, but proceeding with update: {get_error}"
402
+ )
403
+
404
+ # Proceed with the update
405
+ updated_event = await asyncio.to_thread(
406
+ service.events()
407
+ .update(calendarId=calendar_id, eventId=event_id, body=event_body)
408
+ .execute
409
+ )
410
+
411
+ link = updated_event.get("htmlLink", "No link available")
412
+ confirmation_message = f"Successfully modified event '{updated_event.get('summary', summary)}' (ID: {event_id}) for {user_google_email}. Link: {link}"
413
+ logger.info(
414
+ f"Event modified successfully for {user_google_email}. ID: {updated_event.get('id')}, Link: {link}"
415
+ )
416
+ return confirmation_message
417
+ except HttpError as error:
418
+ # Check for 404 Not Found error specifically
419
+ if error.resp.status == 404:
420
+ message = f"Event not found. The event with ID '{event_id}' could not be found in calendar '{calendar_id}'. LLM: The event may have been deleted, or the event ID might be incorrect. Verify the event exists using 'get_events' before attempting to modify it."
421
+ logger.error(f"[modify_event] {message}")
422
+ else:
423
+ message = f"API error modifying event (ID: {event_id}): {error}. You might need to re-authenticate. LLM: Try 'start_google_auth' with the user's email ({user_google_email}) and service_name='Google Calendar'."
424
+ logger.error(message, exc_info=True)
425
+ raise Exception(message)
426
+ except Exception as e:
427
+ message = f"Unexpected error modifying event (ID: {event_id}): {e}."
428
+ logger.exception(message)
429
+ raise Exception(message)
430
+
431
+
432
+ @server.tool()
433
+ @require_google_service("calendar", "calendar_events")
434
+ async def delete_event(service, user_google_email: str, event_id: str, calendar_id: str = "primary") -> str:
435
+ """
436
+ Deletes an existing event.
437
+
438
+ Args:
439
+ user_google_email (str): The user's Google email address. Required.
440
+ event_id (str): The ID of the event to delete.
441
+ calendar_id (str): Calendar ID (default: 'primary').
442
+
443
+ Returns:
444
+ str: Confirmation message of the successful event deletion.
445
+ """
446
+ logger.info(
447
+ f"[delete_event] Invoked. Email: '{user_google_email}', Event ID: {event_id}"
448
+ )
449
+
450
+ try:
451
+ # Log the event ID for debugging
452
+ logger.info(
453
+ f"[delete_event] Attempting to delete event with ID: '{event_id}' in calendar '{calendar_id}'"
454
+ )
455
+
456
+ # Try to get the event first to verify it exists
457
+ try:
458
+ await asyncio.to_thread(
459
+ service.events().get(calendarId=calendar_id, eventId=event_id).execute
460
+ )
461
+ logger.info(
462
+ f"[delete_event] Successfully verified event exists before deletion"
463
+ )
464
+ except HttpError as get_error:
465
+ if get_error.resp.status == 404:
466
+ logger.error(
467
+ f"[delete_event] Event not found during pre-delete verification: {get_error}"
468
+ )
469
+ message = f"Event not found during verification. The event with ID '{event_id}' could not be found in calendar '{calendar_id}'. This may be due to incorrect ID format or the event no longer exists."
470
+ raise Exception(message)
471
+ else:
472
+ logger.warning(
473
+ f"[delete_event] Error during pre-delete verification, but proceeding with deletion: {get_error}"
474
+ )
475
+
476
+ # Proceed with the deletion
477
+ await asyncio.to_thread(
478
+ service.events().delete(calendarId=calendar_id, eventId=event_id).execute
479
+ )
480
+
481
+ confirmation_message = f"Successfully deleted event (ID: {event_id}) from calendar '{calendar_id}' for {user_google_email}."
482
+ logger.info(f"Event deleted successfully for {user_google_email}. ID: {event_id}")
483
+ return confirmation_message
484
+ except HttpError as error:
485
+ # Check for 404 Not Found error specifically
486
+ if error.resp.status == 404:
487
+ message = f"Event not found. The event with ID '{event_id}' could not be found in calendar '{calendar_id}'. LLM: The event may have been deleted already, or the event ID might be incorrect."
488
+ logger.error(f"[delete_event] {message}")
489
+ else:
490
+ message = f"API error deleting event (ID: {event_id}): {error}. You might need to re-authenticate. LLM: Try 'start_google_auth' with the user's email ({user_google_email}) and service_name='Google Calendar'."
491
+ logger.error(message, exc_info=True)
492
+ raise Exception(message)
493
+ except Exception as e:
494
+ message = f"Unexpected error deleting event (ID: {event_id}): {e}."
495
+ logger.exception(message)
496
+ raise Exception(message)
gchat/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ """
2
+ Google Chat MCP Tools Package
3
+ """
4
+ from . import chat_tools
5
+
6
+ __all__ = ['chat_tools']