google-workspace-mcp 1.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 (38) hide show
  1. google_workspace_mcp/__init__.py +3 -0
  2. google_workspace_mcp/__main__.py +43 -0
  3. google_workspace_mcp/app.py +8 -0
  4. google_workspace_mcp/auth/__init__.py +7 -0
  5. google_workspace_mcp/auth/gauth.py +62 -0
  6. google_workspace_mcp/config.py +60 -0
  7. google_workspace_mcp/prompts/__init__.py +3 -0
  8. google_workspace_mcp/prompts/calendar.py +36 -0
  9. google_workspace_mcp/prompts/drive.py +18 -0
  10. google_workspace_mcp/prompts/gmail.py +65 -0
  11. google_workspace_mcp/prompts/slides.py +40 -0
  12. google_workspace_mcp/resources/__init__.py +13 -0
  13. google_workspace_mcp/resources/calendar.py +79 -0
  14. google_workspace_mcp/resources/drive.py +93 -0
  15. google_workspace_mcp/resources/gmail.py +58 -0
  16. google_workspace_mcp/resources/sheets_resources.py +92 -0
  17. google_workspace_mcp/resources/slides.py +421 -0
  18. google_workspace_mcp/services/__init__.py +21 -0
  19. google_workspace_mcp/services/base.py +73 -0
  20. google_workspace_mcp/services/calendar.py +256 -0
  21. google_workspace_mcp/services/docs_service.py +388 -0
  22. google_workspace_mcp/services/drive.py +454 -0
  23. google_workspace_mcp/services/gmail.py +676 -0
  24. google_workspace_mcp/services/sheets_service.py +466 -0
  25. google_workspace_mcp/services/slides.py +959 -0
  26. google_workspace_mcp/tools/__init__.py +7 -0
  27. google_workspace_mcp/tools/calendar.py +229 -0
  28. google_workspace_mcp/tools/docs_tools.py +277 -0
  29. google_workspace_mcp/tools/drive.py +221 -0
  30. google_workspace_mcp/tools/gmail.py +344 -0
  31. google_workspace_mcp/tools/sheets_tools.py +322 -0
  32. google_workspace_mcp/tools/slides.py +478 -0
  33. google_workspace_mcp/utils/__init__.py +1 -0
  34. google_workspace_mcp/utils/markdown_slides.py +504 -0
  35. google_workspace_mcp-1.0.0.dist-info/METADATA +547 -0
  36. google_workspace_mcp-1.0.0.dist-info/RECORD +38 -0
  37. google_workspace_mcp-1.0.0.dist-info/WHEEL +4 -0
  38. google_workspace_mcp-1.0.0.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,454 @@
1
+ """
2
+ Google Drive service implementation for file operations.
3
+ Provides comprehensive file management capabilities through Google Drive API.
4
+ """
5
+
6
+ import base64
7
+ import io
8
+ import logging
9
+ import mimetypes
10
+ import os
11
+ from typing import Any
12
+
13
+ from googleapiclient.errors import HttpError
14
+ from googleapiclient.http import MediaFileUpload, MediaIoBaseDownload
15
+
16
+ from google_workspace_mcp.services.base import BaseGoogleService
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+
21
+ class DriveService(BaseGoogleService):
22
+ """
23
+ Service for interacting with Google Drive API.
24
+ """
25
+
26
+ def __init__(self):
27
+ """Initialize the Drive service."""
28
+ super().__init__("drive", "v3")
29
+
30
+ def search_files(
31
+ self, query: str, page_size: int = 10, shared_drive_id: str | None = None
32
+ ) -> list[dict[str, Any]]:
33
+ """
34
+ Search for files in Google Drive.
35
+
36
+ Args:
37
+ query: Search query string
38
+ page_size: Maximum number of files to return (1-1000)
39
+ shared_drive_id: Optional shared drive ID to search within a specific shared drive
40
+
41
+ Returns:
42
+ List of file metadata dictionaries (id, name, mimeType, etc.) or an error dictionary
43
+ """
44
+ try:
45
+ logger.info(
46
+ f"Searching files with query: '{query}', page_size: {page_size}, shared_drive_id: {shared_drive_id}"
47
+ )
48
+
49
+ # Validate and constrain page_size
50
+ page_size = max(1, min(page_size, 1000))
51
+
52
+ # Format query with proper escaping
53
+ formatted_query = query.replace("'", "\\'")
54
+
55
+ # Build list parameters with shared drive support
56
+ list_params = {
57
+ "q": formatted_query,
58
+ "pageSize": page_size,
59
+ "fields": "files(id, name, mimeType, modifiedTime, size, webViewLink, iconLink)",
60
+ "supportsAllDrives": True,
61
+ "includeItemsFromAllDrives": True,
62
+ }
63
+
64
+ if shared_drive_id:
65
+ list_params["driveId"] = shared_drive_id
66
+ list_params["corpora"] = (
67
+ "drive" # Search within the specified shared drive
68
+ )
69
+ else:
70
+ list_params["corpora"] = (
71
+ "user" # Default to user's files if no specific shared drive ID
72
+ )
73
+
74
+ results = self.service.files().list(**list_params).execute()
75
+ files = results.get("files", [])
76
+
77
+ logger.info(f"Found {len(files)} files matching query '{query}'")
78
+ return files
79
+
80
+ except Exception as e:
81
+ return self.handle_api_error("search_files", e)
82
+
83
+ def read_file_content(self, file_id: str) -> dict[str, Any] | None:
84
+ """
85
+ Read the content of a file from Google Drive.
86
+
87
+ Args:
88
+ file_id: The ID of the file to read
89
+
90
+ Returns:
91
+ Dict containing mimeType and content (possibly base64 encoded)
92
+ """
93
+ try:
94
+ # Get file metadata
95
+ file_metadata = (
96
+ self.service.files()
97
+ .get(fileId=file_id, fields="mimeType, name")
98
+ .execute()
99
+ )
100
+
101
+ original_mime_type = file_metadata.get("mimeType")
102
+ file_name = file_metadata.get("name", "Unknown")
103
+
104
+ logger.info(
105
+ f"Reading file '{file_name}' ({file_id}) with mimeType: {original_mime_type}"
106
+ )
107
+
108
+ # Handle Google Workspace files by exporting
109
+ if original_mime_type.startswith("application/vnd.google-apps."):
110
+ return self._export_google_file(file_id, file_name, original_mime_type)
111
+ return self._download_regular_file(file_id, file_name, original_mime_type)
112
+
113
+ except Exception as e:
114
+ return self.handle_api_error("read_file", e)
115
+
116
+ def get_file_metadata(self, file_id: str) -> dict[str, Any]:
117
+ """
118
+ Get metadata information for a file from Google Drive.
119
+
120
+ Args:
121
+ file_id: The ID of the file to get metadata for
122
+
123
+ Returns:
124
+ Dict containing file metadata or error information
125
+ """
126
+ try:
127
+ if not file_id:
128
+ return {"error": True, "message": "File ID cannot be empty"}
129
+
130
+ logger.info(f"Getting metadata for file with ID: {file_id}")
131
+
132
+ # Retrieve file metadata with comprehensive field selection
133
+ file_metadata = (
134
+ self.service.files()
135
+ .get(
136
+ fileId=file_id,
137
+ fields="id, name, mimeType, size, createdTime, modifiedTime, "
138
+ "webViewLink, webContentLink, iconLink, parents, owners, "
139
+ "shared, trashed, capabilities, permissions, "
140
+ "description, starred, explicitlyTrashed",
141
+ supportsAllDrives=True,
142
+ )
143
+ .execute()
144
+ )
145
+
146
+ logger.info(
147
+ f"Successfully retrieved metadata for file: {file_metadata.get('name', 'Unknown')}"
148
+ )
149
+ return file_metadata
150
+
151
+ except Exception as e:
152
+ return self.handle_api_error("get_file_metadata", e)
153
+
154
+ def create_folder(
155
+ self,
156
+ folder_name: str,
157
+ parent_folder_id: str | None = None,
158
+ shared_drive_id: str | None = None,
159
+ ) -> dict[str, Any]:
160
+ """
161
+ Create a new folder in Google Drive.
162
+
163
+ Args:
164
+ folder_name: The name for the new folder
165
+ parent_folder_id: Optional parent folder ID to create the folder within
166
+ shared_drive_id: Optional shared drive ID to create the folder in a shared drive
167
+
168
+ Returns:
169
+ Dict containing the created folder information or error details
170
+ """
171
+ try:
172
+ if not folder_name or not folder_name.strip():
173
+ return {"error": True, "message": "Folder name cannot be empty"}
174
+
175
+ logger.info(
176
+ f"Creating folder '{folder_name}' with parent_folder_id: {parent_folder_id}, shared_drive_id: {shared_drive_id}"
177
+ )
178
+
179
+ # Build folder metadata
180
+ folder_metadata = {
181
+ "name": folder_name.strip(),
182
+ "mimeType": "application/vnd.google-apps.folder",
183
+ }
184
+
185
+ # Set parent folder if specified
186
+ if parent_folder_id:
187
+ folder_metadata["parents"] = [parent_folder_id]
188
+ elif shared_drive_id:
189
+ # If shared drive is specified but no parent, set shared drive as parent
190
+ folder_metadata["parents"] = [shared_drive_id]
191
+
192
+ # Create the folder with shared drive support
193
+ create_params = {
194
+ "body": folder_metadata,
195
+ "fields": "id, name, parents, webViewLink, createdTime",
196
+ "supportsAllDrives": True,
197
+ }
198
+
199
+ if shared_drive_id:
200
+ create_params["driveId"] = shared_drive_id
201
+
202
+ created_folder = self.service.files().create(**create_params).execute()
203
+
204
+ logger.info(
205
+ f"Successfully created folder '{folder_name}' with ID: {created_folder.get('id')}"
206
+ )
207
+ return created_folder
208
+
209
+ except Exception as e:
210
+ return self.handle_api_error("create_folder", e)
211
+
212
+ def _export_google_file(
213
+ self, file_id: str, file_name: str, mime_type: str
214
+ ) -> dict[str, Any]:
215
+ """Export a Google Workspace file in an appropriate format."""
216
+ # Determine export format
217
+ export_mime_type = None
218
+ if mime_type == "application/vnd.google-apps.document":
219
+ export_mime_type = "text/markdown" # Consistently use markdown for docs
220
+ elif mime_type == "application/vnd.google-apps.spreadsheet":
221
+ export_mime_type = "text/csv"
222
+ elif mime_type == "application/vnd.google-apps.presentation":
223
+ export_mime_type = "text/plain"
224
+ elif mime_type == "application/vnd.google-apps.drawing":
225
+ export_mime_type = "image/png"
226
+
227
+ if not export_mime_type:
228
+ logger.warning(f"Unsupported Google Workspace type: {mime_type}")
229
+ return {
230
+ "error": True,
231
+ "error_type": "unsupported_type",
232
+ "message": f"Unsupported Google Workspace file type: {mime_type}",
233
+ "mimeType": mime_type,
234
+ "operation": "_export_google_file",
235
+ }
236
+
237
+ # Export the file
238
+ try:
239
+ request = self.service.files().export_media(
240
+ fileId=file_id, mimeType=export_mime_type, supportsAllDrives=True
241
+ )
242
+
243
+ content_bytes = self._download_content(request)
244
+ if isinstance(content_bytes, dict) and content_bytes.get("error"):
245
+ return content_bytes
246
+
247
+ # Process the content based on MIME type
248
+ if export_mime_type.startswith("text/"):
249
+ try:
250
+ content = content_bytes.decode("utf-8")
251
+ return {
252
+ "mimeType": export_mime_type,
253
+ "content": content,
254
+ "encoding": "utf-8",
255
+ }
256
+ except UnicodeDecodeError:
257
+ content = base64.b64encode(content_bytes).decode("utf-8")
258
+ return {
259
+ "mimeType": export_mime_type,
260
+ "content": content,
261
+ "encoding": "base64",
262
+ }
263
+ else:
264
+ content = base64.b64encode(content_bytes).decode("utf-8")
265
+ return {
266
+ "mimeType": export_mime_type,
267
+ "content": content,
268
+ "encoding": "base64",
269
+ }
270
+ except Exception as e:
271
+ return self.handle_api_error("_export_google_file", e)
272
+
273
+ def _download_regular_file(
274
+ self, file_id: str, file_name: str, mime_type: str
275
+ ) -> dict[str, Any]:
276
+ """Download a regular (non-Google Workspace) file."""
277
+ request = self.service.files().get_media(fileId=file_id, supportsAllDrives=True)
278
+
279
+ content_bytes = self._download_content(request)
280
+ if isinstance(content_bytes, dict) and content_bytes.get("error"):
281
+ return content_bytes
282
+
283
+ # Process text files
284
+ if mime_type.startswith("text/") or mime_type == "application/json":
285
+ try:
286
+ content = content_bytes.decode("utf-8")
287
+ return {"mimeType": mime_type, "content": content, "encoding": "utf-8"}
288
+ except UnicodeDecodeError:
289
+ logger.warning(
290
+ f"UTF-8 decoding failed for file {file_id} ('{file_name}', {mime_type}). Using base64."
291
+ )
292
+ content = base64.b64encode(content_bytes).decode("utf-8")
293
+ return {
294
+ "mimeType": mime_type,
295
+ "content": content,
296
+ "encoding": "base64",
297
+ }
298
+ else:
299
+ # Binary file
300
+ content = base64.b64encode(content_bytes).decode("utf-8")
301
+ return {"mimeType": mime_type, "content": content, "encoding": "base64"}
302
+
303
+ def _download_content(self, request) -> bytes:
304
+ """Download content from a request."""
305
+ try:
306
+ fh = io.BytesIO()
307
+ downloader = MediaIoBaseDownload(fh, request)
308
+
309
+ done = False
310
+ while not done:
311
+ status, done = downloader.next_chunk()
312
+
313
+ return fh.getvalue()
314
+
315
+ except Exception as e:
316
+ return self.handle_api_error("download_content", e)
317
+
318
+ def upload_file(
319
+ self,
320
+ file_path: str,
321
+ parent_folder_id: str | None = None,
322
+ shared_drive_id: str | None = None,
323
+ ) -> dict[str, Any]:
324
+ """
325
+ Upload a file to Google Drive.
326
+
327
+ Args:
328
+ file_path: Path to the local file to upload
329
+ parent_folder_id: Optional parent folder ID to upload the file to
330
+ shared_drive_id: Optional shared drive ID to upload the file to a shared drive
331
+
332
+ Returns:
333
+ Dict containing file metadata on success, or error information on failure
334
+ """
335
+ try:
336
+ # Check if file exists locally
337
+ if not os.path.exists(file_path):
338
+ logger.error(f"Local file not found for upload: {file_path}")
339
+ return {
340
+ "error": True,
341
+ "error_type": "local_file_error",
342
+ "message": f"Local file not found: {file_path}",
343
+ "operation": "upload_file",
344
+ }
345
+
346
+ file_name = os.path.basename(file_path)
347
+ logger.info(f"Uploading file '{file_name}' from path: {file_path}")
348
+
349
+ # Get file MIME type
350
+ mime_type, _ = mimetypes.guess_type(file_path)
351
+ if mime_type is None:
352
+ mime_type = "application/octet-stream"
353
+
354
+ file_metadata = {"name": file_name}
355
+
356
+ # Set parent folder if specified
357
+ if parent_folder_id:
358
+ file_metadata["parents"] = [parent_folder_id]
359
+ elif shared_drive_id:
360
+ # If shared drive is specified but no parent, set shared drive as parent
361
+ file_metadata["parents"] = [shared_drive_id]
362
+
363
+ media = MediaFileUpload(file_path, mimetype=mime_type)
364
+
365
+ # Prepare create parameters
366
+ create_params = {
367
+ "body": file_metadata,
368
+ "media_body": media,
369
+ "fields": "id,name,mimeType,modifiedTime,size,webViewLink",
370
+ "supportsAllDrives": True,
371
+ }
372
+
373
+ if shared_drive_id:
374
+ create_params["driveId"] = shared_drive_id
375
+
376
+ file = self.service.files().create(**create_params).execute()
377
+
378
+ logger.info(f"Successfully uploaded file with ID: {file.get('id')}")
379
+ return file
380
+
381
+ except HttpError as e:
382
+ return self.handle_api_error("upload_file", e)
383
+ except Exception as e:
384
+ logger.error(f"Non-API error in upload_file: {str(e)}")
385
+ return {
386
+ "error": True,
387
+ "error_type": "local_error",
388
+ "message": f"Error uploading file: {str(e)}",
389
+ "operation": "upload_file",
390
+ }
391
+
392
+ def delete_file(self, file_id: str) -> dict[str, Any]:
393
+ """
394
+ Delete a file from Google Drive.
395
+
396
+ Args:
397
+ file_id: The ID of the file to delete
398
+
399
+ Returns:
400
+ Dict containing success status or error information
401
+ """
402
+ try:
403
+ if not file_id:
404
+ return {"success": False, "message": "File ID cannot be empty"}
405
+
406
+ logger.info(f"Deleting file with ID: {file_id}")
407
+ self.service.files().delete(fileId=file_id).execute()
408
+
409
+ return {"success": True, "message": f"File {file_id} deleted successfully"}
410
+
411
+ except Exception as e:
412
+ return self.handle_api_error("delete_file", e)
413
+
414
+ def list_shared_drives(self, page_size: int = 100) -> list[dict[str, Any]]:
415
+ """
416
+ Lists the user's shared drives.
417
+
418
+ Args:
419
+ page_size: Maximum number of shared drives to return. Max is 100.
420
+
421
+ Returns:
422
+ List of shared drive metadata dictionaries (id, name) or an error dictionary.
423
+ """
424
+ try:
425
+ logger.info(f"Listing shared drives with page size: {page_size}")
426
+ # API allows pageSize up to 100 for drives.list
427
+ actual_page_size = min(max(1, page_size), 100)
428
+
429
+ results = (
430
+ self.service.drives()
431
+ .list(pageSize=actual_page_size, fields="drives(id, name, kind)")
432
+ .execute()
433
+ )
434
+ drives = results.get("drives", [])
435
+
436
+ # Filter for kind='drive#drive' just to be sure, though API should only return these
437
+ processed_drives = [
438
+ {"id": d.get("id"), "name": d.get("name")}
439
+ for d in drives
440
+ if d.get("kind") == "drive#drive" and d.get("id") and d.get("name")
441
+ ]
442
+ logger.info(f"Found {len(processed_drives)} shared drives.")
443
+ return processed_drives
444
+ except HttpError as error:
445
+ logger.error(f"Error listing shared drives: {error}")
446
+ return self.handle_api_error("list_shared_drives", error)
447
+ except Exception as e:
448
+ logger.exception("Unexpected error listing shared drives")
449
+ return {
450
+ "error": True,
451
+ "error_type": "unexpected_service_error",
452
+ "message": str(e),
453
+ "operation": "list_shared_drives",
454
+ }