google-workspace-mcp 1.0.3__py3-none-any.whl → 1.0.4__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.
@@ -4,6 +4,8 @@ Google Docs service implementation.
4
4
 
5
5
  import io
6
6
  import logging
7
+ import urllib.parse
8
+ import urllib.request
7
9
  from typing import Any
8
10
 
9
11
  from googleapiclient.discovery import build
@@ -42,7 +44,9 @@ class DocsService(BaseGoogleService):
42
44
  body = {"title": title}
43
45
  document = self.service.documents().create(body=body).execute()
44
46
  # The response includes documentId, title, and other fields.
45
- logger.info(f"Successfully created document: {document.get('title')} (ID: {document.get('documentId')})")
47
+ logger.info(
48
+ f"Successfully created document: {document.get('title')} (ID: {document.get('documentId')})"
49
+ )
46
50
  return {
47
51
  "document_id": document.get("documentId"),
48
52
  "title": document.get("title"),
@@ -74,7 +78,11 @@ class DocsService(BaseGoogleService):
74
78
  logger.info(f"Fetching metadata for document ID: {document_id}")
75
79
  # The 'fields' parameter can be used to specify which fields to return.
76
80
  # e.g., "documentId,title,body,revisionId,suggestionsViewMode"
77
- document = self.service.documents().get(documentId=document_id, fields="documentId,title").execute()
81
+ document = (
82
+ self.service.documents()
83
+ .get(documentId=document_id, fields="documentId,title")
84
+ .execute()
85
+ )
78
86
  logger.info(
79
87
  f"Successfully fetched metadata for document: {document.get('title')} (ID: {document.get('documentId')})"
80
88
  )
@@ -84,10 +92,14 @@ class DocsService(BaseGoogleService):
84
92
  "document_link": f"https://docs.google.com/document/d/{document.get('documentId')}/edit",
85
93
  }
86
94
  except HttpError as error:
87
- logger.error(f"Error fetching document metadata for ID {document_id}: {error}")
95
+ logger.error(
96
+ f"Error fetching document metadata for ID {document_id}: {error}"
97
+ )
88
98
  return self.handle_api_error("get_document_metadata", error)
89
99
  except Exception as e:
90
- logger.exception(f"Unexpected error fetching document metadata for ID {document_id}")
100
+ logger.exception(
101
+ f"Unexpected error fetching document metadata for ID {document_id}"
102
+ )
91
103
  return {
92
104
  "error": True,
93
105
  "error_type": "unexpected_service_error",
@@ -95,7 +107,9 @@ class DocsService(BaseGoogleService):
95
107
  "operation": "get_document_metadata",
96
108
  }
97
109
 
98
- def get_document_content_as_markdown(self, document_id: str) -> dict[str, Any] | None:
110
+ def get_document_content_as_markdown(
111
+ self, document_id: str
112
+ ) -> dict[str, Any] | None:
99
113
  """
100
114
  Retrieves the content of a Google Document as Markdown using Drive API export.
101
115
 
@@ -106,7 +120,9 @@ class DocsService(BaseGoogleService):
106
120
  A dictionary with 'document_id' and 'markdown_content', or an error dictionary.
107
121
  """
108
122
  try:
109
- logger.info(f"Attempting to export document ID: {document_id} as Markdown via Drive API.")
123
+ logger.info(
124
+ f"Attempting to export document ID: {document_id} as Markdown via Drive API."
125
+ )
110
126
 
111
127
  # Obtain credentials
112
128
  credentials = gauth.get_credentials()
@@ -125,7 +141,9 @@ class DocsService(BaseGoogleService):
125
141
  # Attempt to export as 'text/markdown'
126
142
  # Not all environments or GDoc content might support 'text/markdown' perfectly.
127
143
  # 'text/plain' is a safer fallback.
128
- request = drive_service_client.files().export_media(fileId=document_id, mimeType="text/markdown")
144
+ request = drive_service_client.files().export_media(
145
+ fileId=document_id, mimeType="text/markdown"
146
+ )
129
147
  fh = io.BytesIO()
130
148
  downloader = MediaIoBaseDownload(fh, request)
131
149
  done = False
@@ -136,10 +154,14 @@ class DocsService(BaseGoogleService):
136
154
  content_bytes = fh.getvalue()
137
155
 
138
156
  if content_bytes is None:
139
- raise Exception("Failed to download exported content (bytes object is None).")
157
+ raise Exception(
158
+ "Failed to download exported content (bytes object is None)."
159
+ )
140
160
 
141
161
  markdown_content = content_bytes.decode("utf-8")
142
- logger.info(f"Successfully exported document ID: {document_id} to Markdown.")
162
+ logger.info(
163
+ f"Successfully exported document ID: {document_id} to Markdown."
164
+ )
143
165
  return {"document_id": document_id, "markdown_content": markdown_content}
144
166
 
145
167
  except HttpError as error:
@@ -148,9 +170,13 @@ class DocsService(BaseGoogleService):
148
170
  f"HTTPError exporting document {document_id} as Markdown: {error}. Falling back to text/plain might be an option if this is a common issue for certain docs."
149
171
  )
150
172
  # For now, just return the error from the attempt.
151
- return self.handle_api_error("get_document_content_as_markdown_drive_export", error)
173
+ return self.handle_api_error(
174
+ "get_document_content_as_markdown_drive_export", error
175
+ )
152
176
  except Exception as e:
153
- logger.exception(f"Unexpected error exporting document {document_id} as Markdown: {e}")
177
+ logger.exception(
178
+ f"Unexpected error exporting document {document_id} as Markdown: {e}"
179
+ )
154
180
  return {
155
181
  "error": True,
156
182
  "error_type": "export_error",
@@ -158,7 +184,9 @@ class DocsService(BaseGoogleService):
158
184
  "operation": "get_document_content_as_markdown",
159
185
  }
160
186
 
161
- def append_text(self, document_id: str, text: str, ensure_newline: bool = True) -> dict[str, Any] | None:
187
+ def append_text(
188
+ self, document_id: str, text: str, ensure_newline: bool = True
189
+ ) -> dict[str, Any] | None:
162
190
  """
163
191
  Appends text to the end of a Google Document.
164
192
 
@@ -171,7 +199,9 @@ class DocsService(BaseGoogleService):
171
199
  A dictionary indicating success or an error dictionary.
172
200
  """
173
201
  try:
174
- logger.info(f"Appending text to document ID: {document_id}. ensure_newline={ensure_newline}")
202
+ logger.info(
203
+ f"Appending text to document ID: {document_id}. ensure_newline={ensure_newline}"
204
+ )
175
205
 
176
206
  # To append at the end, we need to find the current end of the body segment.
177
207
  # Get document to find end index. Fields "body(content(endIndex))" might be enough.
@@ -180,7 +210,9 @@ class DocsService(BaseGoogleService):
180
210
  # If ensure_newline, and doc is not empty, prepend "\n" to text.
181
211
 
182
212
  document = (
183
- self.service.documents().get(documentId=document_id, fields="body(content(endIndex,paragraph))").execute()
213
+ self.service.documents()
214
+ .get(documentId=document_id, fields="body(content(endIndex,paragraph))")
215
+ .execute()
184
216
  )
185
217
  end_index = (
186
218
  document.get("body", {}).get("content", [])[-1].get("endIndex")
@@ -189,21 +221,26 @@ class DocsService(BaseGoogleService):
189
221
  )
190
222
 
191
223
  text_to_insert = text
192
- if ensure_newline and end_index > 1: # A new doc might have an end_index of 1 for the initial implicit paragraph
224
+ if (
225
+ ensure_newline and end_index > 1
226
+ ): # A new doc might have an end_index of 1 for the initial implicit paragraph
193
227
  text_to_insert = "\n" + text
194
228
 
195
229
  requests = [
196
230
  {
197
231
  "insertText": {
198
232
  "location": {
199
- "index": end_index - 1 # Insert before the final newline of the document/body
233
+ "index": end_index
234
+ - 1 # Insert before the final newline of the document/body
200
235
  },
201
236
  "text": text_to_insert,
202
237
  }
203
238
  }
204
239
  ]
205
240
 
206
- self.service.documents().batchUpdate(documentId=document_id, body={"requests": requests}).execute()
241
+ self.service.documents().batchUpdate(
242
+ documentId=document_id, body={"requests": requests}
243
+ ).execute()
207
244
  logger.info(f"Successfully appended text to document ID: {document_id}")
208
245
  return {
209
246
  "document_id": document_id,
@@ -214,7 +251,9 @@ class DocsService(BaseGoogleService):
214
251
  logger.error(f"Error appending text to document {document_id}: {error}")
215
252
  return self.handle_api_error("append_text", error)
216
253
  except Exception as e:
217
- logger.exception(f"Unexpected error appending text to document {document_id}")
254
+ logger.exception(
255
+ f"Unexpected error appending text to document {document_id}"
256
+ )
218
257
  return {
219
258
  "error": True,
220
259
  "error_type": "unexpected_service_error",
@@ -222,7 +261,9 @@ class DocsService(BaseGoogleService):
222
261
  "operation": "append_text",
223
262
  }
224
263
 
225
- def prepend_text(self, document_id: str, text: str, ensure_newline: bool = True) -> dict[str, Any] | None:
264
+ def prepend_text(
265
+ self, document_id: str, text: str, ensure_newline: bool = True
266
+ ) -> dict[str, Any] | None:
226
267
  """
227
268
  Prepends text to the beginning of a Google Document.
228
269
 
@@ -235,14 +276,20 @@ class DocsService(BaseGoogleService):
235
276
  A dictionary indicating success or an error dictionary.
236
277
  """
237
278
  try:
238
- logger.info(f"Prepending text to document ID: {document_id}. ensure_newline={ensure_newline}")
279
+ logger.info(
280
+ f"Prepending text to document ID: {document_id}. ensure_newline={ensure_newline}"
281
+ )
239
282
 
240
283
  text_to_insert = text
241
284
  # To prepend, we generally insert at index 1 (after the initial Body segment start).
242
285
  # If ensure_newline is true, and the document isn't empty, add a newline *after* the prepended text.
243
286
  # This requires checking if the document has existing content.
244
287
  if ensure_newline:
245
- document = self.service.documents().get(documentId=document_id, fields="body(content(endIndex))").execute()
288
+ document = (
289
+ self.service.documents()
290
+ .get(documentId=document_id, fields="body(content(endIndex))")
291
+ .execute()
292
+ )
246
293
  current_content_exists = bool(document.get("body", {}).get("content"))
247
294
  if current_content_exists:
248
295
  text_to_insert = text + "\n"
@@ -259,7 +306,9 @@ class DocsService(BaseGoogleService):
259
306
  }
260
307
  ]
261
308
 
262
- self.service.documents().batchUpdate(documentId=document_id, body={"requests": requests}).execute()
309
+ self.service.documents().batchUpdate(
310
+ documentId=document_id, body={"requests": requests}
311
+ ).execute()
263
312
  logger.info(f"Successfully prepended text to document ID: {document_id}")
264
313
  return {
265
314
  "document_id": document_id,
@@ -270,7 +319,9 @@ class DocsService(BaseGoogleService):
270
319
  logger.error(f"Error prepending text to document {document_id}: {error}")
271
320
  return self.handle_api_error("prepend_text", error)
272
321
  except Exception as e:
273
- logger.exception(f"Unexpected error prepending text to document {document_id}")
322
+ logger.exception(
323
+ f"Unexpected error prepending text to document {document_id}"
324
+ )
274
325
  return {
275
326
  "error": True,
276
327
  "error_type": "unexpected_service_error",
@@ -302,7 +353,9 @@ class DocsService(BaseGoogleService):
302
353
  A dictionary indicating success or an error dictionary.
303
354
  """
304
355
  try:
305
- logger.info(f"Inserting text into document ID: {document_id} at index: {index}, segment: {segment_id}")
356
+ logger.info(
357
+ f"Inserting text into document ID: {document_id} at index: {index}, segment: {segment_id}"
358
+ )
306
359
 
307
360
  # Default to index 1 if not provided, which usually targets the start of the body content.
308
361
  # For an empty document, this effectively adds text.
@@ -321,7 +374,9 @@ class DocsService(BaseGoogleService):
321
374
 
322
375
  requests = [request]
323
376
 
324
- self.service.documents().batchUpdate(documentId=document_id, body={"requests": requests}).execute()
377
+ self.service.documents().batchUpdate(
378
+ documentId=document_id, body={"requests": requests}
379
+ ).execute()
325
380
  logger.info(f"Successfully inserted text into document ID: {document_id}")
326
381
  return {
327
382
  "document_id": document_id,
@@ -332,7 +387,9 @@ class DocsService(BaseGoogleService):
332
387
  logger.error(f"Error inserting text into document {document_id}: {error}")
333
388
  return self.handle_api_error("insert_text", error)
334
389
  except Exception as e:
335
- logger.exception(f"Unexpected error inserting text into document {document_id}")
390
+ logger.exception(
391
+ f"Unexpected error inserting text into document {document_id}"
392
+ )
336
393
  return {
337
394
  "error": True,
338
395
  "error_type": "unexpected_service_error",
@@ -340,7 +397,9 @@ class DocsService(BaseGoogleService):
340
397
  "operation": "insert_text",
341
398
  }
342
399
 
343
- def batch_update(self, document_id: str, requests: list[dict]) -> dict[str, Any] | None:
400
+ def batch_update(
401
+ self, document_id: str, requests: list[dict]
402
+ ) -> dict[str, Any] | None:
344
403
  """
345
404
  Applies a list of update requests to the specified Google Document.
346
405
 
@@ -354,16 +413,24 @@ class DocsService(BaseGoogleService):
354
413
  or an error dictionary.
355
414
  """
356
415
  try:
357
- logger.info(f"Executing batchUpdate for document ID: {document_id} with {len(requests)} requests.")
416
+ logger.info(
417
+ f"Executing batchUpdate for document ID: {document_id} with {len(requests)} requests."
418
+ )
358
419
  if not requests:
359
- logger.warning(f"batchUpdate called with no requests for document ID: {document_id}")
420
+ logger.warning(
421
+ f"batchUpdate called with no requests for document ID: {document_id}"
422
+ )
360
423
  return {
361
424
  "document_id": document_id,
362
425
  "replies": [],
363
426
  "message": "No requests provided.",
364
427
  }
365
428
 
366
- response = self.service.documents().batchUpdate(documentId=document_id, body={"requests": requests}).execute()
429
+ response = (
430
+ self.service.documents()
431
+ .batchUpdate(documentId=document_id, body={"requests": requests})
432
+ .execute()
433
+ )
367
434
  # The response object contains a list of 'replies', one for each request,
368
435
  # or it might be empty if all requests were successful without specific replies.
369
436
  # It also contains 'writeControl' and 'documentId'.
@@ -376,13 +443,197 @@ class DocsService(BaseGoogleService):
376
443
  "write_control": response.get("writeControl"),
377
444
  }
378
445
  except HttpError as error:
379
- logger.error(f"Error during batchUpdate for document {document_id}: {error}")
446
+ logger.error(
447
+ f"Error during batchUpdate for document {document_id}: {error}"
448
+ )
380
449
  return self.handle_api_error("batch_update", error)
381
450
  except Exception as e:
382
- logger.exception(f"Unexpected error during batchUpdate for document {document_id}")
451
+ logger.exception(
452
+ f"Unexpected error during batchUpdate for document {document_id}"
453
+ )
383
454
  return {
384
455
  "error": True,
385
456
  "error_type": "unexpected_service_error",
386
457
  "message": str(e),
387
458
  "operation": "batch_update",
388
459
  }
460
+
461
+ def _validate_image_url(self, image_url: str) -> dict[str, Any] | None:
462
+ """
463
+ Validates an image URL for Google Docs API requirements.
464
+
465
+ Returns None if valid, or error dict if invalid.
466
+ """
467
+ if not image_url or not image_url.strip():
468
+ return {
469
+ "error": True,
470
+ "error_type": "validation_error",
471
+ "message": "Image URL cannot be empty",
472
+ }
473
+
474
+ # Check URL length (≤ 2kB)
475
+ if len(image_url) > 2048:
476
+ return {
477
+ "error": True,
478
+ "error_type": "validation_error",
479
+ "message": f"Image URL too long: {len(image_url)} chars (max: 2048)",
480
+ }
481
+
482
+ try:
483
+ # Make HEAD request to check accessibility and get metadata
484
+ req = urllib.request.Request(image_url, method="HEAD")
485
+ req.add_header("User-Agent", "Google-Workspace-MCP/1.0")
486
+
487
+ with urllib.request.urlopen(req, timeout=10) as response:
488
+ content_type = response.headers.get("Content-Type", "").lower()
489
+ content_length = response.headers.get("Content-Length")
490
+
491
+ # Check MIME type (PNG, JPEG, GIF only)
492
+ allowed_types = ["image/png", "image/jpeg", "image/jpg", "image/gif"]
493
+ if not any(
494
+ allowed_type in content_type for allowed_type in allowed_types
495
+ ):
496
+ return {
497
+ "error": True,
498
+ "error_type": "validation_error",
499
+ "message": f"Unsupported image format: {content_type}. Must be PNG, JPEG, or GIF",
500
+ }
501
+
502
+ # Check file size (< 50MB)
503
+ if content_length:
504
+ size_mb = int(content_length) / (1024 * 1024)
505
+ if size_mb >= 50:
506
+ return {
507
+ "error": True,
508
+ "error_type": "validation_error",
509
+ "message": f"Image too large: {size_mb:.1f}MB (max: 50MB)",
510
+ }
511
+
512
+ return None # Valid
513
+
514
+ except urllib.error.HTTPError as e:
515
+ return {
516
+ "error": True,
517
+ "error_type": "validation_error",
518
+ "message": f"Image URL not accessible: HTTP {e.code} {e.reason}",
519
+ }
520
+ except urllib.error.URLError as e:
521
+ return {
522
+ "error": True,
523
+ "error_type": "validation_error",
524
+ "message": f"Image URL not reachable: {e.reason}",
525
+ }
526
+ except Exception as e:
527
+ return {
528
+ "error": True,
529
+ "error_type": "validation_error",
530
+ "message": f"Failed to validate image URL: {str(e)}",
531
+ }
532
+
533
+ def insert_image(
534
+ self,
535
+ document_id: str,
536
+ image_url: str,
537
+ index: int,
538
+ width: float | None = None,
539
+ height: float | None = None,
540
+ ) -> dict[str, Any] | None:
541
+ """
542
+ Inserts an image into a Google Document from a URL at a specific index.
543
+
544
+ Args:
545
+ document_id: The ID of the Google Document.
546
+ image_url: The publicly accessible URL of the image to insert.
547
+ index: The 1-based index in the document where the image will be inserted.
548
+ width: Optional width of the image in points (PT).
549
+ height: Optional height of the image in points (PT).
550
+
551
+ Returns:
552
+ A dictionary containing the inserted image ID and success status, or an error dictionary.
553
+ """
554
+ try:
555
+ logger.info(f"Inserting image into document {document_id} at index {index}")
556
+
557
+ # Validate inputs
558
+ if not document_id or not document_id.strip():
559
+ raise ValueError("Document ID cannot be empty")
560
+
561
+ if not isinstance(index, int) or index < 1:
562
+ raise ValueError("Index must be a positive integer (1-based)")
563
+
564
+ # Validate image URL
565
+ validation_error = self._validate_image_url(image_url)
566
+ if validation_error:
567
+ return validation_error
568
+
569
+ # Build the insert image request
570
+ insert_request = {
571
+ "insertInlineImage": {
572
+ "location": {
573
+ "index": index - 1 # Convert to 0-based index for API
574
+ },
575
+ "uri": image_url,
576
+ }
577
+ }
578
+
579
+ # Add optional dimensions
580
+ if width is not None or height is not None:
581
+ insert_request["insertInlineImage"]["objectSize"] = {}
582
+ if width is not None:
583
+ insert_request["insertInlineImage"]["objectSize"]["width"] = {
584
+ "magnitude": width,
585
+ "unit": "PT",
586
+ }
587
+ if height is not None:
588
+ insert_request["insertInlineImage"]["objectSize"]["height"] = {
589
+ "magnitude": height,
590
+ "unit": "PT",
591
+ }
592
+
593
+ # Execute the batch update
594
+ result = (
595
+ self.service.documents()
596
+ .batchUpdate(
597
+ documentId=document_id, body={"requests": [insert_request]}
598
+ )
599
+ .execute()
600
+ )
601
+
602
+ # Extract the inserted image ID from the reply
603
+ inserted_image_id = None
604
+ if result.get("replies") and len(result["replies"]) > 0:
605
+ reply = result["replies"][0]
606
+ if "insertInlineImage" in reply:
607
+ inserted_image_id = reply["insertInlineImage"].get("objectId")
608
+
609
+ logger.info(
610
+ f"Successfully inserted image {inserted_image_id} into document {document_id}"
611
+ )
612
+
613
+ return {
614
+ "document_id": document_id,
615
+ "inserted_image_id": inserted_image_id,
616
+ "success": True,
617
+ }
618
+
619
+ except ValueError as e:
620
+ logger.error(f"Validation error inserting image: {e}")
621
+ return {
622
+ "error": True,
623
+ "error_type": "validation_error",
624
+ "message": str(e),
625
+ "operation": "insert_image",
626
+ }
627
+ except HttpError as error:
628
+ logger.error(f"Google Docs API error inserting image: {error}")
629
+ return self.handle_api_error("insert_image", error)
630
+ except Exception as e:
631
+ logger.exception(
632
+ f"Unexpected error inserting image into document {document_id}"
633
+ )
634
+ return {
635
+ "error": True,
636
+ "error_type": "unexpected_service_error",
637
+ "message": str(e),
638
+ "operation": "insert_image",
639
+ }
@@ -4,14 +4,14 @@ Provides comprehensive file management capabilities through Google Drive API.
4
4
  """
5
5
 
6
6
  import base64
7
+ import binascii
7
8
  import io
8
9
  import logging
9
10
  import mimetypes
10
- import os
11
11
  from typing import Any
12
12
 
13
13
  from googleapiclient.errors import HttpError
14
- from googleapiclient.http import MediaFileUpload, MediaIoBaseDownload
14
+ from googleapiclient.http import MediaIoBaseDownload, MediaIoBaseUpload
15
15
 
16
16
  from google_workspace_mcp.services.base import BaseGoogleService
17
17
 
@@ -27,7 +27,9 @@ class DriveService(BaseGoogleService):
27
27
  """Initialize the Drive service."""
28
28
  super().__init__("drive", "v3")
29
29
 
30
- def search_files(self, query: str, page_size: int = 10, shared_drive_id: str | None = None) -> list[dict[str, Any]]:
30
+ def search_files(
31
+ self, query: str, page_size: int = 10, shared_drive_id: str | None = None
32
+ ) -> list[dict[str, Any]]:
31
33
  """
32
34
  Search for files in Google Drive.
33
35
 
@@ -40,17 +42,16 @@ class DriveService(BaseGoogleService):
40
42
  List of file metadata dictionaries (id, name, mimeType, etc.) or an error dictionary
41
43
  """
42
44
  try:
43
- logger.info(f"Searching files with query: '{query}', page_size: {page_size}, shared_drive_id: {shared_drive_id}")
45
+ logger.info(
46
+ f"Searching files with query: '{query}', page_size: {page_size}, shared_drive_id: {shared_drive_id}"
47
+ )
44
48
 
45
49
  # Validate and constrain page_size
46
50
  page_size = max(1, min(page_size, 1000))
47
51
 
48
- # Format query with proper escaping
49
- formatted_query = query.replace("'", "\\'")
50
-
51
52
  # Build list parameters with shared drive support
52
53
  list_params = {
53
- "q": formatted_query,
54
+ "q": query, # Use the query directly without modification
54
55
  "pageSize": page_size,
55
56
  "fields": "files(id, name, mimeType, modifiedTime, size, webViewLink, iconLink)",
56
57
  "supportsAllDrives": True,
@@ -59,9 +60,13 @@ class DriveService(BaseGoogleService):
59
60
 
60
61
  if shared_drive_id:
61
62
  list_params["driveId"] = shared_drive_id
62
- list_params["corpora"] = "drive" # Search within the specified shared drive
63
+ list_params["corpora"] = (
64
+ "drive" # Search within the specified shared drive
65
+ )
63
66
  else:
64
- list_params["corpora"] = "user" # Default to user's files if no specific shared drive ID
67
+ list_params["corpora"] = (
68
+ "user" # Default to user's files if no specific shared drive ID
69
+ )
65
70
 
66
71
  results = self.service.files().list(**list_params).execute()
67
72
  files = results.get("files", [])
@@ -84,12 +89,18 @@ class DriveService(BaseGoogleService):
84
89
  """
85
90
  try:
86
91
  # Get file metadata
87
- file_metadata = self.service.files().get(fileId=file_id, fields="mimeType, name").execute()
92
+ file_metadata = (
93
+ self.service.files()
94
+ .get(fileId=file_id, fields="mimeType, name")
95
+ .execute()
96
+ )
88
97
 
89
98
  original_mime_type = file_metadata.get("mimeType")
90
99
  file_name = file_metadata.get("name", "Unknown")
91
100
 
92
- logger.info(f"Reading file '{file_name}' ({file_id}) with mimeType: {original_mime_type}")
101
+ logger.info(
102
+ f"Reading file '{file_name}' ({file_id}) with mimeType: {original_mime_type}"
103
+ )
93
104
 
94
105
  # Handle Google Workspace files by exporting
95
106
  if original_mime_type.startswith("application/vnd.google-apps."):
@@ -129,7 +140,9 @@ class DriveService(BaseGoogleService):
129
140
  .execute()
130
141
  )
131
142
 
132
- logger.info(f"Successfully retrieved metadata for file: {file_metadata.get('name', 'Unknown')}")
143
+ logger.info(
144
+ f"Successfully retrieved metadata for file: {file_metadata.get('name', 'Unknown')}"
145
+ )
133
146
  return file_metadata
134
147
 
135
148
  except Exception as e:
@@ -185,13 +198,17 @@ class DriveService(BaseGoogleService):
185
198
 
186
199
  created_folder = self.service.files().create(**create_params).execute()
187
200
 
188
- logger.info(f"Successfully created folder '{folder_name}' with ID: {created_folder.get('id')}")
201
+ logger.info(
202
+ f"Successfully created folder '{folder_name}' with ID: {created_folder.get('id')}"
203
+ )
189
204
  return created_folder
190
205
 
191
206
  except Exception as e:
192
207
  return self.handle_api_error("create_folder", e)
193
208
 
194
- def _export_google_file(self, file_id: str, file_name: str, mime_type: str) -> dict[str, Any]:
209
+ def _export_google_file(
210
+ self, file_id: str, file_name: str, mime_type: str
211
+ ) -> dict[str, Any]:
195
212
  """Export a Google Workspace file in an appropriate format."""
196
213
  # Determine export format
197
214
  export_mime_type = None
@@ -216,7 +233,9 @@ class DriveService(BaseGoogleService):
216
233
 
217
234
  # Export the file
218
235
  try:
219
- request = self.service.files().export_media(fileId=file_id, mimeType=export_mime_type, supportsAllDrives=True)
236
+ request = self.service.files().export_media(
237
+ fileId=file_id, mimeType=export_mime_type
238
+ )
220
239
 
221
240
  content_bytes = self._download_content(request)
222
241
  if isinstance(content_bytes, dict) and content_bytes.get("error"):
@@ -248,7 +267,9 @@ class DriveService(BaseGoogleService):
248
267
  except Exception as e:
249
268
  return self.handle_api_error("_export_google_file", e)
250
269
 
251
- def _download_regular_file(self, file_id: str, file_name: str, mime_type: str) -> dict[str, Any]:
270
+ def _download_regular_file(
271
+ self, file_id: str, file_name: str, mime_type: str
272
+ ) -> dict[str, Any]:
252
273
  """Download a regular (non-Google Workspace) file."""
253
274
  request = self.service.files().get_media(fileId=file_id, supportsAllDrives=True)
254
275
 
@@ -262,7 +283,9 @@ class DriveService(BaseGoogleService):
262
283
  content = content_bytes.decode("utf-8")
263
284
  return {"mimeType": mime_type, "content": content, "encoding": "utf-8"}
264
285
  except UnicodeDecodeError:
265
- logger.warning(f"UTF-8 decoding failed for file {file_id} ('{file_name}', {mime_type}). Using base64.")
286
+ logger.warning(
287
+ f"UTF-8 decoding failed for file {file_id} ('{file_name}', {mime_type}). Using base64."
288
+ )
266
289
  content = base64.b64encode(content_bytes).decode("utf-8")
267
290
  return {
268
291
  "mimeType": mime_type,
@@ -289,61 +312,60 @@ class DriveService(BaseGoogleService):
289
312
  except Exception as e:
290
313
  return self.handle_api_error("download_content", e)
291
314
 
292
- def upload_file(
315
+ def upload_file_content(
293
316
  self,
294
- file_path: str,
317
+ filename: str,
318
+ content_base64: str,
295
319
  parent_folder_id: str | None = None,
296
320
  shared_drive_id: str | None = None,
297
321
  ) -> dict[str, Any]:
298
322
  """
299
- Upload a file to Google Drive.
323
+ Upload a file to Google Drive using its content.
300
324
 
301
325
  Args:
302
- file_path: Path to the local file to upload
303
- parent_folder_id: Optional parent folder ID to upload the file to
304
- shared_drive_id: Optional shared drive ID to upload the file to a shared drive
326
+ filename: The name for the file in Google Drive.
327
+ content_base64: Base64 encoded content of the file.
328
+ parent_folder_id: Optional parent folder ID.
329
+ shared_drive_id: Optional shared drive ID.
305
330
 
306
331
  Returns:
307
- Dict containing file metadata on success, or error information on failure
332
+ Dict containing file metadata on success, or error information on failure.
308
333
  """
309
334
  try:
310
- # Check if file exists locally
311
- if not os.path.exists(file_path):
312
- logger.error(f"Local file not found for upload: {file_path}")
335
+ logger.info(f"Uploading file '{filename}' from content.")
336
+
337
+ # Decode the base64 content
338
+ try:
339
+ content_bytes = base64.b64decode(content_base64, validate=True)
340
+ except (ValueError, TypeError, binascii.Error) as e:
341
+ logger.error(f"Invalid base64 content for file '{filename}': {e}")
313
342
  return {
314
343
  "error": True,
315
- "error_type": "local_file_error",
316
- "message": f"Local file not found: {file_path}",
317
- "operation": "upload_file",
344
+ "error_type": "invalid_content",
345
+ "message": "Invalid base64 encoded content provided.",
346
+ "operation": "upload_file_content",
318
347
  }
319
348
 
320
- file_name = os.path.basename(file_path)
321
- logger.info(f"Uploading file '{file_name}' from path: {file_path}")
322
-
323
- # Get file MIME type
324
- mime_type, _ = mimetypes.guess_type(file_path)
349
+ # Get file MIME type from filename
350
+ mime_type, _ = mimetypes.guess_type(filename)
325
351
  if mime_type is None:
326
352
  mime_type = "application/octet-stream"
327
353
 
328
- file_metadata = {"name": file_name}
329
-
330
- # Set parent folder if specified
354
+ file_metadata = {"name": filename}
331
355
  if parent_folder_id:
332
356
  file_metadata["parents"] = [parent_folder_id]
333
357
  elif shared_drive_id:
334
- # If shared drive is specified but no parent, set shared drive as parent
335
358
  file_metadata["parents"] = [shared_drive_id]
336
359
 
337
- media = MediaFileUpload(file_path, mimetype=mime_type)
360
+ # Use MediaIoBaseUpload for in-memory content
361
+ media = MediaIoBaseUpload(io.BytesIO(content_bytes), mimetype=mime_type)
338
362
 
339
- # Prepare create parameters
340
363
  create_params = {
341
364
  "body": file_metadata,
342
365
  "media_body": media,
343
366
  "fields": "id,name,mimeType,modifiedTime,size,webViewLink",
344
367
  "supportsAllDrives": True,
345
368
  }
346
-
347
369
  if shared_drive_id:
348
370
  create_params["driveId"] = shared_drive_id
349
371
 
@@ -353,14 +375,14 @@ class DriveService(BaseGoogleService):
353
375
  return file
354
376
 
355
377
  except HttpError as e:
356
- return self.handle_api_error("upload_file", e)
378
+ return self.handle_api_error("upload_file_content", e)
357
379
  except Exception as e:
358
- logger.error(f"Non-API error in upload_file: {str(e)}")
380
+ logger.error(f"Non-API error in upload_file_content: {str(e)}")
359
381
  return {
360
382
  "error": True,
361
383
  "error_type": "local_error",
362
- "message": f"Error uploading file: {str(e)}",
363
- "operation": "upload_file",
384
+ "message": f"Error uploading file from content: {str(e)}",
385
+ "operation": "upload_file_content",
364
386
  }
365
387
 
366
388
  def delete_file(self, file_id: str) -> dict[str, Any]:
@@ -400,7 +422,11 @@ class DriveService(BaseGoogleService):
400
422
  # API allows pageSize up to 100 for drives.list
401
423
  actual_page_size = min(max(1, page_size), 100)
402
424
 
403
- results = self.service.drives().list(pageSize=actual_page_size, fields="drives(id, name, kind)").execute()
425
+ results = (
426
+ self.service.drives()
427
+ .list(pageSize=actual_page_size, fields="drives(id, name, kind)")
428
+ .execute()
429
+ )
404
430
  drives = results.get("drives", [])
405
431
 
406
432
  # Filter for kind='drive#drive' just to be sure, though API should only return these
@@ -37,7 +37,9 @@ async def docs_create_document(title: str) -> dict[str, Any]:
37
37
  raise ValueError(result.get("message", "Error creating document"))
38
38
 
39
39
  if not result or not result.get("document_id"):
40
- raise ValueError(f"Failed to create document '{title}' or did not receive a document ID.")
40
+ raise ValueError(
41
+ f"Failed to create document '{title}' or did not receive a document ID."
42
+ )
41
43
 
42
44
  return result
43
45
 
@@ -57,7 +59,9 @@ async def docs_get_document_metadata(document_id: str) -> dict[str, Any]:
57
59
  A dictionary containing the document's 'document_id', 'title', and 'document_link',
58
60
  or an error message.
59
61
  """
60
- logger.info(f"Executing docs_get_document_metadata tool for document_id: '{document_id}'")
62
+ logger.info(
63
+ f"Executing docs_get_document_metadata tool for document_id: '{document_id}'"
64
+ )
61
65
  if not document_id or not document_id.strip():
62
66
  raise ValueError("Document ID cannot be empty.")
63
67
 
@@ -88,7 +92,9 @@ async def docs_get_content_as_markdown(document_id: str) -> dict[str, Any]:
88
92
  A dictionary containing the 'document_id' and 'markdown_content',
89
93
  or an error message.
90
94
  """
91
- logger.info(f"Executing docs_get_content_as_markdown tool for document_id: '{document_id}'")
95
+ logger.info(
96
+ f"Executing docs_get_content_as_markdown tool for document_id: '{document_id}'"
97
+ )
92
98
  if not document_id or not document_id.strip():
93
99
  raise ValueError("Document ID cannot be empty.")
94
100
 
@@ -96,10 +102,14 @@ async def docs_get_content_as_markdown(document_id: str) -> dict[str, Any]:
96
102
  result = docs_service.get_document_content_as_markdown(document_id=document_id)
97
103
 
98
104
  if isinstance(result, dict) and result.get("error"):
99
- raise ValueError(result.get("message", "Error retrieving document content as Markdown"))
105
+ raise ValueError(
106
+ result.get("message", "Error retrieving document content as Markdown")
107
+ )
100
108
 
101
109
  if not result or "markdown_content" not in result:
102
- raise ValueError(f"Failed to retrieve Markdown content for document '{document_id}'.")
110
+ raise ValueError(
111
+ f"Failed to retrieve Markdown content for document '{document_id}'."
112
+ )
103
113
 
104
114
  return result
105
115
 
@@ -108,7 +118,9 @@ async def docs_get_content_as_markdown(document_id: str) -> dict[str, Any]:
108
118
  name="docs_append_text",
109
119
  description="Appends text to the end of a specified Google Document.",
110
120
  )
111
- async def docs_append_text(document_id: str, text: str, ensure_newline: bool = True) -> dict[str, Any]:
121
+ async def docs_append_text(
122
+ document_id: str, text: str, ensure_newline: bool = True
123
+ ) -> dict[str, Any]:
112
124
  """
113
125
  Appends the given text to the end of the specified Google Document.
114
126
 
@@ -127,7 +139,9 @@ async def docs_append_text(document_id: str, text: str, ensure_newline: bool = T
127
139
  # Text can be empty, that's fine.
128
140
 
129
141
  docs_service = DocsService()
130
- result = docs_service.append_text(document_id=document_id, text=text, ensure_newline=ensure_newline)
142
+ result = docs_service.append_text(
143
+ document_id=document_id, text=text, ensure_newline=ensure_newline
144
+ )
131
145
 
132
146
  if isinstance(result, dict) and result.get("error"):
133
147
  raise ValueError(result.get("message", "Error appending text to document"))
@@ -142,7 +156,9 @@ async def docs_append_text(document_id: str, text: str, ensure_newline: bool = T
142
156
  name="docs_prepend_text",
143
157
  description="Prepends text to the beginning of a specified Google Document.",
144
158
  )
145
- async def docs_prepend_text(document_id: str, text: str, ensure_newline: bool = True) -> dict[str, Any]:
159
+ async def docs_prepend_text(
160
+ document_id: str, text: str, ensure_newline: bool = True
161
+ ) -> dict[str, Any]:
146
162
  """
147
163
  Prepends the given text to the beginning of the specified Google Document.
148
164
 
@@ -161,7 +177,9 @@ async def docs_prepend_text(document_id: str, text: str, ensure_newline: bool =
161
177
  # Text can be empty, that's fine.
162
178
 
163
179
  docs_service = DocsService()
164
- result = docs_service.prepend_text(document_id=document_id, text=text, ensure_newline=ensure_newline)
180
+ result = docs_service.prepend_text(
181
+ document_id=document_id, text=text, ensure_newline=ensure_newline
182
+ )
165
183
 
166
184
  if isinstance(result, dict) and result.get("error"):
167
185
  raise ValueError(result.get("message", "Error prepending text to document"))
@@ -195,13 +213,17 @@ async def docs_insert_text(
195
213
  Returns:
196
214
  A dictionary indicating success or an error message.
197
215
  """
198
- logger.info(f"Executing docs_insert_text tool for document_id: '{document_id}' at index: {index}")
216
+ logger.info(
217
+ f"Executing docs_insert_text tool for document_id: '{document_id}' at index: {index}"
218
+ )
199
219
  if not document_id or not document_id.strip():
200
220
  raise ValueError("Document ID cannot be empty.")
201
221
  # Text can be empty, that's a valid insertion (though perhaps not useful).
202
222
 
203
223
  docs_service = DocsService()
204
- result = docs_service.insert_text(document_id=document_id, text=text, index=index, segment_id=segment_id)
224
+ result = docs_service.insert_text(
225
+ document_id=document_id, text=text, index=index, segment_id=segment_id
226
+ )
205
227
 
206
228
  if isinstance(result, dict) and result.get("error"):
207
229
  raise ValueError(result.get("message", "Error inserting text into document"))
@@ -231,7 +253,9 @@ async def docs_batch_update(document_id: str, requests: list[dict]) -> dict[str,
231
253
  A dictionary containing the API response from the batchUpdate call (includes replies for each request),
232
254
  or an error message.
233
255
  """
234
- logger.info(f"Executing docs_batch_update tool for document_id: '{document_id}' with {len(requests)} requests.")
256
+ logger.info(
257
+ f"Executing docs_batch_update tool for document_id: '{document_id}' with {len(requests)} requests."
258
+ )
235
259
  if not document_id or not document_id.strip():
236
260
  raise ValueError("Document ID cannot be empty.")
237
261
  if not isinstance(requests, list):
@@ -243,9 +267,72 @@ async def docs_batch_update(document_id: str, requests: list[dict]) -> dict[str,
243
267
  result = docs_service.batch_update(document_id=document_id, requests=requests)
244
268
 
245
269
  if isinstance(result, dict) and result.get("error"):
246
- raise ValueError(result.get("message", "Error executing batch update on document"))
270
+ raise ValueError(
271
+ result.get("message", "Error executing batch update on document")
272
+ )
247
273
 
248
274
  if not result: # Should be caught by error dict check
249
275
  raise ValueError(f"Failed to execute batch update on document '{document_id}'.")
250
276
 
251
277
  return result # Return the full response which includes documentId and replies
278
+
279
+
280
+ @mcp.tool(
281
+ name="docs_insert_image",
282
+ description="Inserts an image into a Google Document from a URL at a specific index. The image URL must be publicly accessible and in PNG, JPEG, or GIF format.",
283
+ )
284
+ async def docs_insert_image(
285
+ document_id: str,
286
+ image_url: str,
287
+ index: int,
288
+ width: float | None = None,
289
+ height: float | None = None,
290
+ ) -> dict[str, Any]:
291
+ """
292
+ Inserts an image into a Google Document from a URL at a specific index.
293
+
294
+ Args:
295
+ document_id: The ID of the Google Document.
296
+ image_url: The publicly accessible URL of the image to insert.
297
+ index: The 1-based index in the document where the image will be inserted.
298
+ width: Optional width of the image in points (PT).
299
+ height: Optional height of the image in points (PT).
300
+
301
+ Returns:
302
+ A dictionary containing the inserted image ID and success status, or an error message.
303
+ """
304
+ logger.info(
305
+ f"Executing docs_insert_image tool for document_id: '{document_id}' at index: {index}"
306
+ )
307
+
308
+ if not document_id or not document_id.strip():
309
+ raise ValueError("Document ID cannot be empty.")
310
+
311
+ if not image_url or not image_url.strip():
312
+ raise ValueError("Image URL cannot be empty.")
313
+
314
+ if not isinstance(index, int) or index < 1:
315
+ raise ValueError("Index must be a positive integer (1-based).")
316
+
317
+ if width is not None and (not isinstance(width, int | float) or width <= 0):
318
+ raise ValueError("Width must be a positive number.")
319
+
320
+ if height is not None and (not isinstance(height, int | float) or height <= 0):
321
+ raise ValueError("Height must be a positive number.")
322
+
323
+ docs_service = DocsService()
324
+ result = docs_service.insert_image(
325
+ document_id=document_id,
326
+ image_url=image_url,
327
+ index=index,
328
+ width=width,
329
+ height=height,
330
+ )
331
+
332
+ if isinstance(result, dict) and result.get("error"):
333
+ raise ValueError(result.get("message", "Error inserting image into document"))
334
+
335
+ if not result or not result.get("success"):
336
+ raise ValueError(f"Failed to insert image into document '{document_id}'.")
337
+
338
+ return result
@@ -42,7 +42,9 @@ async def drive_search_files(
42
42
  raise ValueError("Query cannot be empty")
43
43
 
44
44
  drive_service = DriveService()
45
- files = drive_service.search_files(query=query, page_size=page_size, shared_drive_id=shared_drive_id)
45
+ files = drive_service.search_files(
46
+ query=query, page_size=page_size, shared_drive_id=shared_drive_id
47
+ )
46
48
 
47
49
  if isinstance(files, dict) and files.get("error"):
48
50
  raise ValueError(f"Search failed: {files.get('message', 'Unknown error')}")
@@ -82,18 +84,20 @@ async def drive_read_file_content(file_id: str) -> dict[str, Any]:
82
84
 
83
85
  @mcp.tool(
84
86
  name="drive_upload_file",
85
- description="Upload a local file to Google Drive. Requires a local file path.",
87
+ description="Uploads a file to Google Drive by providing its content directly.",
86
88
  )
87
89
  async def drive_upload_file(
88
- file_path: str,
90
+ filename: str,
91
+ content_base64: str,
89
92
  parent_folder_id: str | None = None,
90
93
  shared_drive_id: str | None = None,
91
94
  ) -> dict[str, Any]:
92
95
  """
93
- Upload a local file to Google Drive.
96
+ Uploads a file to Google Drive using its base64 encoded content.
94
97
 
95
98
  Args:
96
- file_path: Path to the local file to upload.
99
+ filename: The desired name for the file in Google Drive (e.g., "report.pdf").
100
+ content_base64: The content of the file, encoded in base64.
97
101
  parent_folder_id: Optional parent folder ID to upload the file to.
98
102
  shared_drive_id: Optional shared drive ID to upload the file to a shared drive.
99
103
 
@@ -101,14 +105,17 @@ async def drive_upload_file(
101
105
  A dictionary containing the uploaded file metadata or an error.
102
106
  """
103
107
  logger.info(
104
- f"Executing drive_upload_file with path: '{file_path}', parent_folder_id: {parent_folder_id}, shared_drive_id: {shared_drive_id}"
108
+ f"Executing drive_upload_file with filename: '{filename}', parent_folder_id: {parent_folder_id}, shared_drive_id: {shared_drive_id}"
105
109
  )
106
- if not file_path or not file_path.strip():
107
- raise ValueError("File path cannot be empty")
110
+ if not filename or not filename.strip():
111
+ raise ValueError("Filename cannot be empty")
112
+ if not content_base64 or not content_base64.strip():
113
+ raise ValueError("File content (content_base64) cannot be empty")
108
114
 
109
115
  drive_service = DriveService()
110
- result = drive_service.upload_file(
111
- file_path=file_path,
116
+ result = drive_service.upload_file_content(
117
+ filename=filename,
118
+ content_base64=content_base64,
112
119
  parent_folder_id=parent_folder_id,
113
120
  shared_drive_id=shared_drive_id,
114
121
  )
@@ -154,7 +161,9 @@ async def drive_create_folder(
154
161
  )
155
162
 
156
163
  if isinstance(result, dict) and result.get("error"):
157
- raise ValueError(f"Folder creation failed: {result.get('message', 'Unknown error')}")
164
+ raise ValueError(
165
+ f"Folder creation failed: {result.get('message', 'Unknown error')}"
166
+ )
158
167
 
159
168
  return result
160
169
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: google-workspace-mcp
3
- Version: 1.0.3
3
+ Version: 1.0.4
4
4
  Summary: MCP server for Google Workspace integration
5
5
  Author-email: Arclio Team <info@arclio.com>
6
6
  License: MIT
@@ -11,7 +11,7 @@ Requires-Dist: google-auth-httplib2>=0.1.0
11
11
  Requires-Dist: google-auth-oauthlib>=1.0.0
12
12
  Requires-Dist: google-auth>=2.22.0
13
13
  Requires-Dist: markdown>=3.5.0
14
- Requires-Dist: markdowndeck>=0.1.2
14
+ Requires-Dist: markdowndeck>=0.1.3
15
15
  Requires-Dist: mcp>=1.7.0
16
16
  Requires-Dist: python-dotenv>=1.0.0
17
17
  Requires-Dist: pytz>=2023.3
@@ -18,21 +18,21 @@ google_workspace_mcp/resources/slides.py,sha256=m2KVCYUOJ9uKMVWXXw9lXNem3OFT438X
18
18
  google_workspace_mcp/services/__init__.py,sha256=crw4YinYFi7QDfJZUCcUqPliRtlViSE7QSqAX8j33eE,473
19
19
  google_workspace_mcp/services/base.py,sha256=uLtFB158kaY_n3JWKgW2kxQ2tx9SP9zxX-PWuPxydFI,2378
20
20
  google_workspace_mcp/services/calendar.py,sha256=_FvgDKfMvFWIamDHOimY3cVK2zPk0o7iUbbKeJehaHY,8370
21
- google_workspace_mcp/services/docs_service.py,sha256=KRNn4iAjeQJjC3loR3zCQQ-RV7S6-qaWwaT_SvT4onk,17297
22
- google_workspace_mcp/services/drive.py,sha256=iE5IyzA52Wm21ItIlhBnPPsiSkarZbnNc4mcBOmMdU8,16361
21
+ google_workspace_mcp/services/docs_service.py,sha256=e67bvDb0lAmDbSXYRxPAIH93rXPbHS0Fq07c-eBcy08,25286
22
+ google_workspace_mcp/services/drive.py,sha256=9ReL9XkRLpK7ZS1KngHVv2ZGykeOo4N5ncp-gSYcAEA,16804
23
23
  google_workspace_mcp/services/gmail.py,sha256=M6trjL9uFOe5WnBORyic4Y7lU4Z0X9VnEq-yU7RY-No,24846
24
24
  google_workspace_mcp/services/sheets_service.py,sha256=67glYCTuQynka_vAXxCsM2U5SMuRNXXpTff2G-X-fT0,18068
25
25
  google_workspace_mcp/services/slides.py,sha256=qfXh6GBr2JkCGPgVmwvV4NapLlzF7A1_WlYQR8EXMEE,37310
26
26
  google_workspace_mcp/tools/__init__.py,sha256=vwa7hV8HwrLqs3Sf7RTrt1MlVZ-KjptXuFDSJt5tWzA,107
27
27
  google_workspace_mcp/tools/calendar.py,sha256=KX42ZHNMcO69GuU-Re-37HY1Kk0jGM0dwUSrkexi_So,7890
28
- google_workspace_mcp/tools/docs_tools.py,sha256=BbfLJKAZnKlfHLluM3iZE9oxMELX066xR7qzHkN4PbM,10313
29
- google_workspace_mcp/tools/drive.py,sha256=Goj5v9RpQAq5yuSeX_uAkCYAy_7wnaQLo5wDqBlSTLg,6766
28
+ google_workspace_mcp/tools/docs_tools.py,sha256=L4zgjBAZxGYEnvNzUj_-leNXc1tIghzmDBinng2uMjI,12677
29
+ google_workspace_mcp/tools/drive.py,sha256=HUL_ozcTvWOIXKM0eQpLLYY6Yu_V2aFyhPVWW8GTpyw,7132
30
30
  google_workspace_mcp/tools/gmail.py,sha256=nkbN6IC8yhSxrNwxUVRh9qAakomKJjGP4-MEKJoTnpA,11269
31
31
  google_workspace_mcp/tools/sheets_tools.py,sha256=1lC_6oeXCZQDFI4f_hHrOZOnu-t6iVeQ2ErYwhS172Q,11760
32
32
  google_workspace_mcp/tools/slides.py,sha256=GTtkIaQf6GMtzaxu8wlAemWcY5n5Y6RMEwWILPoiMFo,15382
33
33
  google_workspace_mcp/utils/__init__.py,sha256=jKEfO2DhR_lwsRSoUgceixDwzkGVlp_vmbrz_6AHOk0,50
34
34
  google_workspace_mcp/utils/markdown_slides.py,sha256=G7EbK3DDki90hNilGzbczs2e345v-HjYB5GWhFbEtw4,21015
35
- google_workspace_mcp-1.0.3.dist-info/METADATA,sha256=vXR7YN8UH6dGiXf3hNY-EesLp0sklg5j629JB-u5R4s,25104
36
- google_workspace_mcp-1.0.3.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
37
- google_workspace_mcp-1.0.3.dist-info/entry_points.txt,sha256=t9eBYTnGzEBpiiRx_SCTqforubwM6Ebf5mszIj-MjeA,79
38
- google_workspace_mcp-1.0.3.dist-info/RECORD,,
35
+ google_workspace_mcp-1.0.4.dist-info/METADATA,sha256=wvx5j35AYP6PIi55dZYiokXZCU-t-xWjmK5XQjOVaD0,25104
36
+ google_workspace_mcp-1.0.4.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
37
+ google_workspace_mcp-1.0.4.dist-info/entry_points.txt,sha256=t9eBYTnGzEBpiiRx_SCTqforubwM6Ebf5mszIj-MjeA,79
38
+ google_workspace_mcp-1.0.4.dist-info/RECORD,,