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.
- google_workspace_mcp/__init__.py +3 -0
- google_workspace_mcp/__main__.py +43 -0
- google_workspace_mcp/app.py +8 -0
- google_workspace_mcp/auth/__init__.py +7 -0
- google_workspace_mcp/auth/gauth.py +62 -0
- google_workspace_mcp/config.py +60 -0
- google_workspace_mcp/prompts/__init__.py +3 -0
- google_workspace_mcp/prompts/calendar.py +36 -0
- google_workspace_mcp/prompts/drive.py +18 -0
- google_workspace_mcp/prompts/gmail.py +65 -0
- google_workspace_mcp/prompts/slides.py +40 -0
- google_workspace_mcp/resources/__init__.py +13 -0
- google_workspace_mcp/resources/calendar.py +79 -0
- google_workspace_mcp/resources/drive.py +93 -0
- google_workspace_mcp/resources/gmail.py +58 -0
- google_workspace_mcp/resources/sheets_resources.py +92 -0
- google_workspace_mcp/resources/slides.py +421 -0
- google_workspace_mcp/services/__init__.py +21 -0
- google_workspace_mcp/services/base.py +73 -0
- google_workspace_mcp/services/calendar.py +256 -0
- google_workspace_mcp/services/docs_service.py +388 -0
- google_workspace_mcp/services/drive.py +454 -0
- google_workspace_mcp/services/gmail.py +676 -0
- google_workspace_mcp/services/sheets_service.py +466 -0
- google_workspace_mcp/services/slides.py +959 -0
- google_workspace_mcp/tools/__init__.py +7 -0
- google_workspace_mcp/tools/calendar.py +229 -0
- google_workspace_mcp/tools/docs_tools.py +277 -0
- google_workspace_mcp/tools/drive.py +221 -0
- google_workspace_mcp/tools/gmail.py +344 -0
- google_workspace_mcp/tools/sheets_tools.py +322 -0
- google_workspace_mcp/tools/slides.py +478 -0
- google_workspace_mcp/utils/__init__.py +1 -0
- google_workspace_mcp/utils/markdown_slides.py +504 -0
- google_workspace_mcp-1.0.0.dist-info/METADATA +547 -0
- google_workspace_mcp-1.0.0.dist-info/RECORD +38 -0
- google_workspace_mcp-1.0.0.dist-info/WHEEL +4 -0
- 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
|
+
}
|