universal-mcp 0.1.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.
@@ -0,0 +1,487 @@
1
+ from universal_mcp.applications.application import APIApplication
2
+ from universal_mcp.integrations import Integration
3
+ from loguru import logger
4
+ from datetime import datetime, timedelta
5
+
6
+ class GoogleCalendarApp(APIApplication):
7
+ def __init__(self, integration: Integration) -> None:
8
+ super().__init__(name="google-calendar", integration=integration)
9
+ self.base_api_url = "https://www.googleapis.com/calendar/v3/calendars/primary"
10
+
11
+ def _get_headers(self):
12
+ if not self.integration:
13
+ raise ValueError("Integration not configured for GoogleCalendarApp")
14
+ credentials = self.integration.get_credentials()
15
+ if "headers" in credentials:
16
+ return credentials["headers"]
17
+ return {
18
+ "Authorization": f"Bearer {credentials['access_token']}",
19
+ "Accept": "application/json"
20
+ }
21
+
22
+ def _format_datetime(self, dt_string: str) -> str:
23
+ """Format a datetime string from ISO format to a human-readable format.
24
+
25
+ Args:
26
+ dt_string: A datetime string in ISO format (e.g., "2023-06-01T10:00:00Z")
27
+
28
+ Returns:
29
+ A formatted datetime string (e.g., "2023-06-01 10:00 AM") or the original string with
30
+ "(All day)" appended if it's just a date
31
+ """
32
+ if not dt_string or dt_string == "Unknown":
33
+ return "Unknown"
34
+
35
+ # Check if it's just a date (all-day event) or a datetime
36
+ if "T" in dt_string:
37
+ # It's a datetime - parse and format it
38
+ try:
39
+ # Handle Z (UTC) suffix by replacing with +00:00 timezone
40
+ if dt_string.endswith("Z"):
41
+ dt_string = dt_string.replace("Z", "+00:00")
42
+
43
+ # Parse the ISO datetime string
44
+ dt = datetime.fromisoformat(dt_string)
45
+
46
+ # Format to a more readable form
47
+ return dt.strftime("%Y-%m-%d %I:%M %p")
48
+ except ValueError:
49
+ # In case of parsing error, return the original
50
+ logger.warning(f"Could not parse datetime string: {dt_string}")
51
+ return dt_string
52
+ else:
53
+ # It's just a date (all-day event)
54
+ return f"{dt_string} (All day)"
55
+
56
+ def get_today_events(self, days: int = 1, max_results: int = None, time_zone: str = None) -> str:
57
+ """Get events from your Google Calendar for today or a specified number of days
58
+
59
+ Args:
60
+ days: Number of days to retrieve events for (default: 1, which is just today)
61
+ max_results: Maximum number of events to return (optional)
62
+ time_zone: Time zone used in the response (optional, default is calendar's time zone)
63
+
64
+ Returns:
65
+ A formatted list of events or an error message
66
+ """
67
+ # Get today's date in ISO format
68
+ today = datetime.utcnow().date()
69
+ end_date = today + timedelta(days=days)
70
+
71
+ # Format dates for API
72
+ time_min = f"{today.isoformat()}T00:00:00Z"
73
+ time_max = f"{end_date.isoformat()}T00:00:00Z"
74
+
75
+ url = f"{self.base_api_url}/events"
76
+
77
+ # Build query parameters
78
+ params = {
79
+ "timeMin": time_min,
80
+ "timeMax": time_max,
81
+ "singleEvents": "true",
82
+ "orderBy": "startTime"
83
+ }
84
+
85
+ if max_results is not None:
86
+ params["maxResults"] = max_results
87
+
88
+ if time_zone:
89
+ params["timeZone"] = time_zone
90
+
91
+ date_range = "today" if days == 1 else f"the next {days} days"
92
+ logger.info(f"Retrieving calendar events for {date_range}")
93
+
94
+ response = self._get(url, params=params)
95
+ response.raise_for_status()
96
+
97
+ events = response.json().get("items", [])
98
+ if not events:
99
+ return f"No events scheduled for {date_range}."
100
+
101
+ result = f"Events for {date_range}:\n\n"
102
+ for event in events:
103
+ # Extract event date and time
104
+ start = event.get("start", {})
105
+ event_date = start.get("date", start.get("dateTime", "")).split("T")[0] if "T" in start.get("dateTime", "") else start.get("date", "")
106
+
107
+ # Extract and format time
108
+ start_time = start.get("dateTime", start.get("date", "All day"))
109
+
110
+ # Format the time display
111
+ if "T" in start_time: # It's a datetime
112
+ formatted_time = self._format_datetime(start_time)
113
+ # For multi-day view, keep the date; for single day, just show time
114
+ if days > 1:
115
+ time_display = formatted_time
116
+ else:
117
+ # Extract just the time part
118
+ time_display = formatted_time.split(" ")[1] + " " + formatted_time.split(" ")[2]
119
+ else: # It's an all-day event
120
+ if days > 1:
121
+ time_display = f"{event_date} (All day)"
122
+ else:
123
+ time_display = "All day"
124
+
125
+ # Get event details
126
+ summary = event.get("summary", "Untitled event")
127
+ event_id = event.get("id", "No ID")
128
+
129
+ result += f"- {time_display}: {summary} (ID: {event_id})\n"
130
+
131
+ return result
132
+
133
+ def get_event(self, event_id: str, max_attendees: int = None, time_zone: str = None) -> str:
134
+ """Get a specific event from your Google Calendar by ID
135
+
136
+ Args:
137
+ event_id: The ID of the event to retrieve
138
+ max_attendees: Optional. The maximum number of attendees to include in the response
139
+ time_zone: Optional. Time zone used in the response (default is calendar's time zone)
140
+
141
+ Returns:
142
+ A formatted event details or an error message
143
+ """
144
+ url = f"{self.base_api_url}/events/{event_id}"
145
+
146
+ # Build query parameters
147
+ params = {}
148
+ if max_attendees is not None:
149
+ params["maxAttendees"] = max_attendees
150
+ if time_zone:
151
+ params["timeZone"] = time_zone
152
+
153
+ logger.info(f"Retrieving calendar event with ID: {event_id}")
154
+
155
+ response = self._get(url, params=params)
156
+ response.raise_for_status()
157
+
158
+ event = response.json()
159
+
160
+ # Extract event details
161
+ summary = event.get("summary", "Untitled event")
162
+ description = event.get("description", "No description")
163
+ location = event.get("location", "No location specified")
164
+
165
+ # Format dates
166
+ start = event.get("start", {})
167
+ end = event.get("end", {})
168
+
169
+ start_time = start.get("dateTime", start.get("date", "Unknown"))
170
+ end_time = end.get("dateTime", end.get("date", "Unknown"))
171
+
172
+ # Format datetimes using the helper function
173
+ start_formatted = self._format_datetime(start_time)
174
+ end_formatted = self._format_datetime(end_time)
175
+
176
+ # Get creator and organizer
177
+ creator = event.get("creator", {}).get("email", "Unknown")
178
+ organizer = event.get("organizer", {}).get("email", "Unknown")
179
+
180
+ # Check if it's a recurring event
181
+ recurrence = "Yes" if "recurrence" in event else "No"
182
+
183
+ # Get attendees if any
184
+ attendees = event.get("attendees", [])
185
+ attendee_info = ""
186
+ if attendees:
187
+ attendee_info = "\nAttendees:\n"
188
+ for i, attendee in enumerate(attendees, 1):
189
+ email = attendee.get("email", "No email")
190
+ name = attendee.get("displayName", email)
191
+ response_status = attendee.get("responseStatus", "Unknown")
192
+
193
+ status_mapping = {
194
+ "accepted": "Accepted",
195
+ "declined": "Declined",
196
+ "tentative": "Maybe",
197
+ "needsAction": "Not responded"
198
+ }
199
+
200
+ formatted_status = status_mapping.get(response_status, response_status)
201
+ attendee_info += f" {i}. {name} ({email}) - {formatted_status}\n"
202
+
203
+ # Format the response
204
+ result = f"Event: {summary}\n"
205
+ result += f"ID: {event_id}\n"
206
+ result += f"When: {start_formatted} to {end_formatted}\n"
207
+ result += f"Where: {location}\n"
208
+ result += f"Description: {description}\n"
209
+ result += f"Creator: {creator}\n"
210
+ result += f"Organizer: {organizer}\n"
211
+ result += f"Recurring: {recurrence}\n"
212
+ result += attendee_info
213
+
214
+ return result
215
+
216
+ def list_events(self, max_results: int = 10, time_min: str = None, time_max: str = None,
217
+ q: str = None, order_by: str = "startTime", single_events: bool = True,
218
+ time_zone: str = None, page_token: str = None) -> str:
219
+ """List events from your Google Calendar with various filtering options
220
+
221
+ Args:
222
+ max_results: Maximum number of events to return (default: 10, max: 2500)
223
+ time_min: Start time (ISO format, e.g. '2023-12-01T00:00:00Z') - defaults to now if not specified
224
+ time_max: End time (ISO format, e.g. '2023-12-31T23:59:59Z')
225
+ q: Free text search terms (searches summary, description, location, attendees, etc.)
226
+ order_by: How to order results - 'startTime' (default) or 'updated'
227
+ single_events: Whether to expand recurring events (default: True)
228
+ time_zone: Time zone used in the response (default is calendar's time zone)
229
+ page_token: Token for retrieving a specific page of results
230
+
231
+ Returns:
232
+ A formatted list of events or an error message
233
+ """
234
+ url = f"{self.base_api_url}/events"
235
+
236
+ # Build query parameters
237
+ params = {
238
+ "maxResults": max_results,
239
+ "singleEvents": str(single_events).lower(),
240
+ "orderBy": order_by
241
+ }
242
+
243
+ # Set time boundaries if provided, otherwise default to now for time_min
244
+ if time_min:
245
+ params["timeMin"] = time_min
246
+ else:
247
+ # Default to current time if not specified
248
+ now = datetime.utcnow().isoformat() + "Z" # 'Z' indicates UTC time
249
+ params["timeMin"] = now
250
+
251
+ if time_max:
252
+ params["timeMax"] = time_max
253
+
254
+ # Add optional filters if provided
255
+ if q:
256
+ params["q"] = q
257
+
258
+ if time_zone:
259
+ params["timeZone"] = time_zone
260
+
261
+ if page_token:
262
+ params["pageToken"] = page_token
263
+
264
+ logger.info(f"Retrieving calendar events with params: {params}")
265
+
266
+ response = self._get(url, params=params)
267
+ response.raise_for_status()
268
+
269
+ data = response.json()
270
+ events = data.get("items", [])
271
+
272
+ if not events:
273
+ return "No events found matching your criteria."
274
+
275
+ # Extract calendar information
276
+ calendar_summary = data.get("summary", "Your Calendar")
277
+ time_zone_info = data.get("timeZone", "Unknown")
278
+
279
+ result = f"Events from {calendar_summary} (Time Zone: {time_zone_info}):\n\n"
280
+
281
+ # Process and format each event
282
+ for i, event in enumerate(events, 1):
283
+ # Get basic event details
284
+ event_id = event.get("id", "No ID")
285
+ summary = event.get("summary", "Untitled event")
286
+
287
+ # Get event times and format them
288
+ start = event.get("start", {})
289
+ start_time = start.get("dateTime", start.get("date", "Unknown"))
290
+
291
+ # Format the start time using the helper function
292
+ start_formatted = self._format_datetime(start_time)
293
+
294
+ # Get location if available
295
+ location = event.get("location", "No location specified")
296
+
297
+ # Check if it's a recurring event
298
+ is_recurring = "recurrence" in event
299
+ recurring_info = " (Recurring)" if is_recurring else ""
300
+
301
+ # Format the event information
302
+ result += f"{i}. {summary}{recurring_info}\n"
303
+ result += f" ID: {event_id}\n"
304
+ result += f" When: {start_formatted}\n"
305
+ result += f" Where: {location}\n"
306
+
307
+ # Add a separator between events
308
+ if i < len(events):
309
+ result += "\n"
310
+
311
+ # Add pagination info if available
312
+ if "nextPageToken" in data:
313
+ next_token = data.get("nextPageToken")
314
+ result += f"\nMore events available. Use page_token='{next_token}' to see more."
315
+
316
+ return result
317
+
318
+ def quick_add_event(self, text: str, send_updates: str = "none") -> str:
319
+ """Create a calendar event using natural language description
320
+
321
+ This method allows you to quickly create an event using a simple text string,
322
+ similar to how you would add events in the Google Calendar UI.
323
+
324
+ Args:
325
+ text: Text describing the event (e.g., "Meeting with John at Coffee Shop tomorrow 3pm-4pm")
326
+ send_updates: Who should receive notifications - "all", "externalOnly", or "none" (default)
327
+
328
+ Returns:
329
+ A confirmation message with the created event details or an error message
330
+ """
331
+ url = f"{self.base_api_url}/events/quickAdd"
332
+
333
+ # Use params argument instead of manually constructing URL
334
+ params = {
335
+ "text": text,
336
+ "sendUpdates": send_updates
337
+ }
338
+
339
+ logger.info(f"Creating event via quickAdd: '{text}'")
340
+
341
+ # Pass params to _post method
342
+ response = self._post(url, data=None, params=params)
343
+ response.raise_for_status()
344
+
345
+ event = response.json()
346
+
347
+ # Extract event details
348
+ event_id = event.get("id", "Unknown")
349
+ summary = event.get("summary", "Untitled event")
350
+
351
+ # Format dates
352
+ start = event.get("start", {})
353
+ end = event.get("end", {})
354
+
355
+ start_time = start.get("dateTime", start.get("date", "Unknown"))
356
+ end_time = end.get("dateTime", end.get("date", "Unknown"))
357
+
358
+ # Format datetimes using the helper function
359
+ start_formatted = self._format_datetime(start_time)
360
+ end_formatted = self._format_datetime(end_time)
361
+
362
+ # Get location if available
363
+ location = event.get("location", "No location specified")
364
+
365
+ # Format the confirmation message
366
+ result = f"Successfully created event!\n\n"
367
+ result += f"Summary: {summary}\n"
368
+ result += f"When: {start_formatted}"
369
+
370
+ # Only add end time if it's different from start (for all-day events they might be the same)
371
+ if start_formatted != end_formatted:
372
+ result += f" to {end_formatted}"
373
+
374
+ result += f"\nWhere: {location}\n"
375
+ result += f"Event ID: {event_id}\n"
376
+
377
+ # Add a note about viewing the event
378
+ result += f"\nUse get_event('{event_id}') to see full details."
379
+
380
+ return result
381
+
382
+ def get_event_instances(self, event_id: str, max_results: int = 25, time_min: str = None,
383
+ time_max: str = None, time_zone: str = None, show_deleted: bool = False,
384
+ page_token: str = None) -> str:
385
+ """Get all instances of a recurring event
386
+
387
+ This method retrieves all occurrences of a recurring event within a specified time range.
388
+
389
+ Args:
390
+ event_id: ID of the recurring event
391
+ max_results: Maximum number of event instances to return (default: 25, max: 2500)
392
+ time_min: Lower bound (inclusive) for event's end time (ISO format)
393
+ time_max: Upper bound (exclusive) for event's start time (ISO format)
394
+ time_zone: Time zone used in the response (default is calendar's time zone)
395
+ show_deleted: Whether to include deleted instances (default: False)
396
+ page_token: Token for retrieving a specific page of results
397
+
398
+ Returns:
399
+ A formatted list of event instances or an error message
400
+ """
401
+ url = f"{self.base_api_url}/events/{event_id}/instances"
402
+
403
+ # Build query parameters
404
+ params = {
405
+ "maxResults": max_results,
406
+ "showDeleted": str(show_deleted).lower()
407
+ }
408
+
409
+ # Add optional parameters if provided
410
+ if time_min:
411
+ params["timeMin"] = time_min
412
+
413
+ if time_max:
414
+ params["timeMax"] = time_max
415
+
416
+ if time_zone:
417
+ params["timeZone"] = time_zone
418
+
419
+ if page_token:
420
+ params["pageToken"] = page_token
421
+
422
+ logger.info(f"Retrieving instances of recurring event with ID: {event_id}")
423
+
424
+ response = self._get(url, params=params)
425
+ response.raise_for_status()
426
+
427
+ data = response.json()
428
+ instances = data.get("items", [])
429
+
430
+ if not instances:
431
+ return f"No instances found for recurring event with ID: {event_id}"
432
+
433
+ # Extract event summary from the first instance
434
+ parent_summary = instances[0].get("summary", "Untitled recurring event")
435
+
436
+ result = f"Instances of recurring event: {parent_summary}\n\n"
437
+
438
+ # Process and format each instance
439
+ for i, instance in enumerate(instances, 1):
440
+ # Get instance ID and status
441
+ instance_id = instance.get("id", "No ID")
442
+ status = instance.get("status", "confirmed")
443
+
444
+ # Format status for display
445
+ status_display = ""
446
+ if status == "cancelled":
447
+ status_display = " [CANCELLED]"
448
+ elif status == "tentative":
449
+ status_display = " [TENTATIVE]"
450
+
451
+ # Get instance time
452
+ start = instance.get("start", {})
453
+ original_start_time = instance.get("originalStartTime", {})
454
+
455
+ # Determine if this is a modified instance
456
+ is_modified = original_start_time and "dateTime" in original_start_time
457
+ modified_indicator = " [MODIFIED]" if is_modified else ""
458
+
459
+ # Get the time information
460
+ start_time = start.get("dateTime", start.get("date", "Unknown"))
461
+
462
+ # Format the time using the helper function
463
+ formatted_time = self._format_datetime(start_time)
464
+
465
+ # Format the instance information
466
+ result += f"{i}. {formatted_time}{status_display}{modified_indicator}\n"
467
+ result += f" Instance ID: {instance_id}\n"
468
+
469
+ # Show original start time if modified
470
+ if is_modified:
471
+ orig_time = original_start_time.get("dateTime", original_start_time.get("date", "Unknown"))
472
+ orig_formatted = self._format_datetime(orig_time)
473
+ result += f" Original time: {orig_formatted}\n"
474
+
475
+ # Add a separator between instances
476
+ if i < len(instances):
477
+ result += "\n"
478
+
479
+ # Add pagination info if available
480
+ if "nextPageToken" in data:
481
+ next_token = data.get("nextPageToken")
482
+ result += f"\nMore instances available. Use page_token='{next_token}' to see more."
483
+
484
+ return result
485
+
486
+ def list_tools(self):
487
+ return [self.get_event, self.get_today_events, self.list_events, self.quick_add_event, self.get_event_instances]