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.
- auth/__init__.py +1 -0
- auth/google_auth.py +549 -0
- auth/oauth_callback_server.py +241 -0
- auth/oauth_responses.py +223 -0
- auth/scopes.py +108 -0
- auth/service_decorator.py +404 -0
- core/__init__.py +1 -0
- core/server.py +214 -0
- core/utils.py +162 -0
- gcalendar/__init__.py +1 -0
- gcalendar/calendar_tools.py +496 -0
- gchat/__init__.py +6 -0
- gchat/chat_tools.py +254 -0
- gdocs/__init__.py +0 -0
- gdocs/docs_tools.py +244 -0
- gdrive/__init__.py +0 -0
- gdrive/drive_tools.py +362 -0
- gforms/__init__.py +3 -0
- gforms/forms_tools.py +318 -0
- gmail/__init__.py +1 -0
- gmail/gmail_tools.py +807 -0
- gsheets/__init__.py +23 -0
- gsheets/sheets_tools.py +393 -0
- gslides/__init__.py +0 -0
- gslides/slides_tools.py +316 -0
- main.py +160 -0
- workspace_mcp-0.2.0.dist-info/METADATA +29 -0
- workspace_mcp-0.2.0.dist-info/RECORD +32 -0
- workspace_mcp-0.2.0.dist-info/WHEEL +5 -0
- workspace_mcp-0.2.0.dist-info/entry_points.txt +2 -0
- workspace_mcp-0.2.0.dist-info/licenses/LICENSE +21 -0
- workspace_mcp-0.2.0.dist-info/top_level.txt +11 -0
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)
|