google-workspace-mcp 1.0.5__py3-none-any.whl → 1.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -27,8 +27,137 @@ class DriveService(BaseGoogleService):
27
27
  """Initialize the Drive service."""
28
28
  super().__init__("drive", "v3")
29
29
 
30
+ def _escape_drive_query(self, query: str) -> str:
31
+ """
32
+ Smart query processing for Drive API queries.
33
+
34
+ Automatically converts simple text searches to proper Drive API format:
35
+ - Simple text like "sprint planning" becomes fullText contains 'sprint planning'
36
+ - Already formatted queries like "name contains 'test'" are passed through
37
+ - Handles apostrophe escaping automatically
38
+ - Validates queries to prevent invalid syntax
39
+
40
+ Args:
41
+ query: Query string (can be simple text or Drive API format)
42
+
43
+ Returns:
44
+ Properly formatted query string for Drive API
45
+
46
+ Raises:
47
+ ValueError: If query contains invalid syntax like unsupported parentheses
48
+ """
49
+ if not query or not query.strip():
50
+ return ""
51
+
52
+ query = query.strip()
53
+
54
+ # Remove surrounding double quotes if present
55
+ if query.startswith('"') and query.endswith('"'):
56
+ query = query[1:-1]
57
+
58
+ # Check for unsupported syntax - parentheses are not supported in files.list queries
59
+ if "(" in query or ")" in query:
60
+ raise ValueError(
61
+ "Parentheses are not supported in Drive file search queries. "
62
+ "Use separate search calls or restructure your query. "
63
+ "For complex searches, try: fullText contains 'term1' OR fullText contains 'term2'"
64
+ )
65
+
66
+ # Check if this is already a structured Drive API query
67
+ # Look for Drive API operators and syntax
68
+ drive_api_indicators = [
69
+ " contains ",
70
+ " = ",
71
+ " != ",
72
+ " > ",
73
+ " < ",
74
+ " >= ",
75
+ " <= ",
76
+ " in ",
77
+ " and ",
78
+ " or ",
79
+ " not ",
80
+ "mimeType",
81
+ "parents",
82
+ "trashed",
83
+ "sharedWithMe",
84
+ "starred",
85
+ "modifiedTime",
86
+ "createdTime",
87
+ "name",
88
+ "fullText",
89
+ ]
90
+
91
+ is_structured_query = any(
92
+ indicator in query.lower() for indicator in drive_api_indicators
93
+ )
94
+
95
+ if is_structured_query:
96
+ # Validate that it's not a mixed query (text + operators + API syntax)
97
+ # Mixed queries like "ArcLio (sprint OR planning) modifiedTime > '2024-01-01'" are invalid
98
+ words = query.split()
99
+ has_unquoted_text = False
100
+
101
+ for word in words:
102
+ # Skip quoted strings, operators, and API field names
103
+ if (
104
+ word.startswith("'")
105
+ or word.endswith("'")
106
+ or word.lower() in ["and", "or", "not", "contains", "in"]
107
+ or any(
108
+ field in word.lower()
109
+ for field in [
110
+ "mimetype",
111
+ "name",
112
+ "fulltext",
113
+ "modifiedtime",
114
+ "createdtime",
115
+ "trashed",
116
+ "starred",
117
+ "sharedwithme",
118
+ "parents",
119
+ ]
120
+ )
121
+ or word in ["=", "!=", ">", "<", ">=", "<="]
122
+ or word.startswith("'")
123
+ and word.endswith("'")
124
+ ):
125
+ continue
126
+
127
+ # Check if this looks like unquoted text mixed with operators
128
+ if not word.replace("-", "").replace("_", "").isalnum():
129
+ continue
130
+
131
+ # If we find unquoted alphanumeric text in a structured query, it might be mixed
132
+ if (
133
+ any(op in query.lower() for op in [" and ", " or "])
134
+ and len(word) > 2
135
+ ):
136
+ has_unquoted_text = True
137
+ break
138
+
139
+ if has_unquoted_text:
140
+ raise ValueError(
141
+ "Mixed query syntax detected. Use either: "
142
+ "1) Simple text: 'sprint planning meeting' "
143
+ "2) Drive API syntax: fullText contains 'sprint' OR fullText contains 'planning' "
144
+ "3) Structured queries: modifiedTime > '2024-01-01' AND name contains 'sprint'"
145
+ )
146
+
147
+ # This looks like a valid structured Drive API query, just escape apostrophes
148
+ return query.replace("'", "\\'")
149
+ # This is a simple text search, convert to fullText search
150
+ # Escape apostrophes in the search term
151
+ escaped_text = query.replace("'", "\\'")
152
+ # Wrap in fullText contains for better search results
153
+ return f"fullText contains '{escaped_text}'"
154
+
30
155
  def search_files(
31
- self, query: str, page_size: int = 10, shared_drive_id: str | None = None
156
+ self,
157
+ query: str,
158
+ page_size: int = 10,
159
+ shared_drive_id: str | None = None,
160
+ include_shared_drives: bool = True,
32
161
  ) -> list[dict[str, Any]]:
33
162
  """
34
163
  Search for files in Google Drive.
@@ -37,36 +166,42 @@ class DriveService(BaseGoogleService):
37
166
  query: Search query string
38
167
  page_size: Maximum number of files to return (1-1000)
39
168
  shared_drive_id: Optional shared drive ID to search within a specific shared drive
169
+ include_shared_drives: Whether to include shared drives in search (default True)
40
170
 
41
171
  Returns:
42
172
  List of file metadata dictionaries (id, name, mimeType, etc.) or an error dictionary
43
173
  """
44
174
  try:
45
175
  logger.info(
46
- f"Searching files with query: '{query}', page_size: {page_size}, shared_drive_id: {shared_drive_id}"
176
+ f"Searching files with query: '{query}', page_size: {page_size}, "
177
+ f"shared_drive_id: {shared_drive_id}, include_shared_drives: {include_shared_drives}"
47
178
  )
48
179
 
49
180
  # Validate and constrain page_size
50
181
  page_size = max(1, min(page_size, 1000))
51
182
 
52
- # Build list parameters with shared drive support
183
+ # Properly escape the query for Drive API
184
+ escaped_query = self._escape_drive_query(query)
185
+
186
+ # Build list parameters with comprehensive shared drive support
53
187
  list_params = {
54
- "q": query, # Use the query directly without modification
188
+ "q": escaped_query,
55
189
  "pageSize": page_size,
56
- "fields": "files(id, name, mimeType, modifiedTime, size, webViewLink, iconLink)",
190
+ "fields": "files(id, name, mimeType, modifiedTime, size, webViewLink, iconLink, parents)",
57
191
  "supportsAllDrives": True,
58
192
  "includeItemsFromAllDrives": True,
59
193
  }
60
194
 
61
195
  if shared_drive_id:
196
+ # Search within a specific shared drive
62
197
  list_params["driveId"] = shared_drive_id
63
- list_params["corpora"] = (
64
- "drive" # Search within the specified shared drive
65
- )
198
+ list_params["corpora"] = "drive"
199
+ elif include_shared_drives:
200
+ # Search across all drives (user's files + shared drives + shared folders)
201
+ list_params["corpora"] = "allDrives"
66
202
  else:
67
- list_params["corpora"] = (
68
- "user" # Default to user's files if no specific shared drive ID
69
- )
203
+ # Search only user's personal files
204
+ list_params["corpora"] = "user"
70
205
 
71
206
  results = self.service.files().list(**list_params).execute()
72
207
  files = results.get("files", [])
@@ -91,7 +226,7 @@ class DriveService(BaseGoogleService):
91
226
  # Get file metadata
92
227
  file_metadata = (
93
228
  self.service.files()
94
- .get(fileId=file_id, fields="mimeType, name")
229
+ .get(fileId=file_id, fields="mimeType, name", supportsAllDrives=True)
95
230
  .execute()
96
231
  )
97
232
 
@@ -246,21 +381,27 @@ class DriveService(BaseGoogleService):
246
381
  try:
247
382
  content = content_bytes.decode("utf-8")
248
383
  return {
249
- "mimeType": export_mime_type,
384
+ "file_id": file_id,
385
+ "name": file_name,
386
+ "mime_type": export_mime_type,
250
387
  "content": content,
251
388
  "encoding": "utf-8",
252
389
  }
253
390
  except UnicodeDecodeError:
254
391
  content = base64.b64encode(content_bytes).decode("utf-8")
255
392
  return {
256
- "mimeType": export_mime_type,
393
+ "file_id": file_id,
394
+ "name": file_name,
395
+ "mime_type": export_mime_type,
257
396
  "content": content,
258
397
  "encoding": "base64",
259
398
  }
260
399
  else:
261
400
  content = base64.b64encode(content_bytes).decode("utf-8")
262
401
  return {
263
- "mimeType": export_mime_type,
402
+ "file_id": file_id,
403
+ "name": file_name,
404
+ "mime_type": export_mime_type,
264
405
  "content": content,
265
406
  "encoding": "base64",
266
407
  }
@@ -281,21 +422,35 @@ class DriveService(BaseGoogleService):
281
422
  if mime_type.startswith("text/") or mime_type == "application/json":
282
423
  try:
283
424
  content = content_bytes.decode("utf-8")
284
- return {"mimeType": mime_type, "content": content, "encoding": "utf-8"}
425
+ return {
426
+ "file_id": file_id,
427
+ "name": file_name,
428
+ "mime_type": mime_type,
429
+ "content": content,
430
+ "encoding": "utf-8",
431
+ }
285
432
  except UnicodeDecodeError:
286
433
  logger.warning(
287
434
  f"UTF-8 decoding failed for file {file_id} ('{file_name}', {mime_type}). Using base64."
288
435
  )
289
436
  content = base64.b64encode(content_bytes).decode("utf-8")
290
437
  return {
291
- "mimeType": mime_type,
438
+ "file_id": file_id,
439
+ "name": file_name,
440
+ "mime_type": mime_type,
292
441
  "content": content,
293
442
  "encoding": "base64",
294
443
  }
295
444
  else:
296
445
  # Binary file
297
446
  content = base64.b64encode(content_bytes).decode("utf-8")
298
- return {"mimeType": mime_type, "content": content, "encoding": "base64"}
447
+ return {
448
+ "file_id": file_id,
449
+ "name": file_name,
450
+ "mime_type": mime_type,
451
+ "content": content,
452
+ "encoding": "base64",
453
+ }
299
454
 
300
455
  def _download_content(self, request) -> bytes:
301
456
  """Download content from a request."""
@@ -448,3 +603,98 @@ class DriveService(BaseGoogleService):
448
603
  "message": str(e),
449
604
  "operation": "list_shared_drives",
450
605
  }
606
+
607
+ def share_file_with_domain(
608
+ self, file_id: str, domain: str, role: str = "reader"
609
+ ) -> dict[str, Any]:
610
+ """
611
+ Shares a file with an entire domain.
612
+
613
+ Args:
614
+ file_id: The ID of the file to share.
615
+ domain: The domain to share the file with (e.g., 'example.com').
616
+ role: The permission role ('reader', 'commenter', 'writer'). Defaults to 'reader'.
617
+
618
+ Returns:
619
+ A dictionary containing the permission details or an error dictionary.
620
+ """
621
+ try:
622
+ if not file_id or not domain:
623
+ raise ValueError("File ID and domain are required.")
624
+
625
+ logger.info(
626
+ f"Sharing file {file_id} with domain '{domain}' as role '{role}'"
627
+ )
628
+
629
+ permission = {"type": "domain", "role": role, "domain": domain}
630
+
631
+ # Create the permission
632
+ permission_result = (
633
+ self.service.permissions()
634
+ .create(
635
+ fileId=file_id,
636
+ body=permission,
637
+ sendNotificationEmail=False, # Avoid spamming the domain
638
+ supportsAllDrives=True,
639
+ )
640
+ .execute()
641
+ )
642
+
643
+ logger.info(
644
+ f"Successfully created domain permission ID: {permission_result.get('id')}"
645
+ )
646
+ return {
647
+ "success": True,
648
+ "file_id": file_id,
649
+ "permission_id": permission_result.get("id"),
650
+ "domain": domain,
651
+ "role": role,
652
+ }
653
+
654
+ except HttpError as error:
655
+ # Check for a 403 error related to sharing policies
656
+ if error.resp.status == 403:
657
+ error_content = error.content.decode("utf-8")
658
+ if (
659
+ "cannotShareTeamDriveItem" in error_content
660
+ or "sharingRateLimitExceeded" in error_content
661
+ ):
662
+ logger.error(
663
+ f"Domain sharing policy prevents sharing file {file_id}: {error_content}"
664
+ )
665
+ # Return a more specific error message
666
+ return self.handle_api_error(
667
+ "share_file_with_domain_policy_error", error
668
+ )
669
+
670
+ return self.handle_api_error("share_file_with_domain", error)
671
+ except Exception as e:
672
+ return self.handle_api_error("share_file_with_domain", e)
673
+
674
+ def _get_or_create_data_folder(self) -> str:
675
+ """
676
+ Finds the dedicated folder for storing chart data, creating it if it doesn't exist.
677
+ The result is cached to avoid repeated API calls within the same session.
678
+
679
+ Returns:
680
+ The ID of the data folder.
681
+ """
682
+ folder_name = "[MCP] Generated Chart Data"
683
+ query = f"name = '{folder_name}' and mimeType = 'application/vnd.google-apps.folder' and trashed = false"
684
+
685
+ logger.info(f"Searching for data folder: '{folder_name}'")
686
+ search_result = self.search_files(query=query, page_size=1)
687
+
688
+ if search_result and len(search_result) > 0:
689
+ folder_id = search_result[0]["id"]
690
+ logger.info(f"Found existing data folder with ID: {folder_id}")
691
+ return folder_id
692
+ logger.info("Data folder not found. Creating a new one.")
693
+ create_result = self.create_folder(folder_name=folder_name)
694
+ if create_result and not create_result.get("error"):
695
+ folder_id = create_result["id"]
696
+ logger.info(f"Successfully created data folder with ID: {folder_id}")
697
+ return folder_id
698
+ raise RuntimeError(
699
+ "Failed to create the necessary data folder in Google Drive."
700
+ )