google-workspace-mcp 1.1.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.
- google_workspace_mcp/__main__.py +5 -6
- google_workspace_mcp/models.py +486 -0
- google_workspace_mcp/services/calendar.py +14 -4
- google_workspace_mcp/services/drive.py +237 -14
- google_workspace_mcp/services/sheets_service.py +273 -35
- google_workspace_mcp/services/slides.py +42 -1829
- google_workspace_mcp/tools/calendar.py +116 -100
- google_workspace_mcp/tools/docs_tools.py +99 -57
- google_workspace_mcp/tools/drive.py +112 -92
- google_workspace_mcp/tools/gmail.py +131 -66
- google_workspace_mcp/tools/sheets_tools.py +137 -64
- google_workspace_mcp/tools/slides.py +295 -743
- {google_workspace_mcp-1.1.5.dist-info → google_workspace_mcp-1.2.0.dist-info}/METADATA +3 -2
- {google_workspace_mcp-1.1.5.dist-info → google_workspace_mcp-1.2.0.dist-info}/RECORD +16 -17
- google_workspace_mcp/tools/add_image.py +0 -1781
- google_workspace_mcp/utils/unit_conversion.py +0 -201
- {google_workspace_mcp-1.1.5.dist-info → google_workspace_mcp-1.2.0.dist-info}/WHEEL +0 -0
- {google_workspace_mcp-1.1.5.dist-info → google_workspace_mcp-1.2.0.dist-info}/entry_points.txt +0 -0
@@ -29,20 +29,128 @@ class DriveService(BaseGoogleService):
|
|
29
29
|
|
30
30
|
def _escape_drive_query(self, query: str) -> str:
|
31
31
|
"""
|
32
|
-
|
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
|
33
39
|
|
34
40
|
Args:
|
35
|
-
query: Query string (
|
41
|
+
query: Query string (can be simple text or Drive API format)
|
36
42
|
|
37
43
|
Returns:
|
38
|
-
|
44
|
+
Properly formatted query string for Drive API
|
45
|
+
|
46
|
+
Raises:
|
47
|
+
ValueError: If query contains invalid syntax like unsupported parentheses
|
39
48
|
"""
|
40
|
-
|
41
|
-
|
49
|
+
if not query or not query.strip():
|
50
|
+
return ""
|
51
|
+
|
52
|
+
query = query.strip()
|
53
|
+
|
54
|
+
# Remove surrounding double quotes if present
|
42
55
|
if query.startswith('"') and query.endswith('"'):
|
43
56
|
query = query[1:-1]
|
44
57
|
|
45
|
-
|
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}'"
|
46
154
|
|
47
155
|
def search_files(
|
48
156
|
self,
|
@@ -118,7 +226,7 @@ class DriveService(BaseGoogleService):
|
|
118
226
|
# Get file metadata
|
119
227
|
file_metadata = (
|
120
228
|
self.service.files()
|
121
|
-
.get(fileId=file_id, fields="mimeType, name")
|
229
|
+
.get(fileId=file_id, fields="mimeType, name", supportsAllDrives=True)
|
122
230
|
.execute()
|
123
231
|
)
|
124
232
|
|
@@ -261,7 +369,7 @@ class DriveService(BaseGoogleService):
|
|
261
369
|
# Export the file
|
262
370
|
try:
|
263
371
|
request = self.service.files().export_media(
|
264
|
-
fileId=file_id, mimeType=export_mime_type
|
372
|
+
fileId=file_id, mimeType=export_mime_type
|
265
373
|
)
|
266
374
|
|
267
375
|
content_bytes = self._download_content(request)
|
@@ -273,21 +381,27 @@ class DriveService(BaseGoogleService):
|
|
273
381
|
try:
|
274
382
|
content = content_bytes.decode("utf-8")
|
275
383
|
return {
|
276
|
-
"
|
384
|
+
"file_id": file_id,
|
385
|
+
"name": file_name,
|
386
|
+
"mime_type": export_mime_type,
|
277
387
|
"content": content,
|
278
388
|
"encoding": "utf-8",
|
279
389
|
}
|
280
390
|
except UnicodeDecodeError:
|
281
391
|
content = base64.b64encode(content_bytes).decode("utf-8")
|
282
392
|
return {
|
283
|
-
"
|
393
|
+
"file_id": file_id,
|
394
|
+
"name": file_name,
|
395
|
+
"mime_type": export_mime_type,
|
284
396
|
"content": content,
|
285
397
|
"encoding": "base64",
|
286
398
|
}
|
287
399
|
else:
|
288
400
|
content = base64.b64encode(content_bytes).decode("utf-8")
|
289
401
|
return {
|
290
|
-
"
|
402
|
+
"file_id": file_id,
|
403
|
+
"name": file_name,
|
404
|
+
"mime_type": export_mime_type,
|
291
405
|
"content": content,
|
292
406
|
"encoding": "base64",
|
293
407
|
}
|
@@ -308,21 +422,35 @@ class DriveService(BaseGoogleService):
|
|
308
422
|
if mime_type.startswith("text/") or mime_type == "application/json":
|
309
423
|
try:
|
310
424
|
content = content_bytes.decode("utf-8")
|
311
|
-
return {
|
425
|
+
return {
|
426
|
+
"file_id": file_id,
|
427
|
+
"name": file_name,
|
428
|
+
"mime_type": mime_type,
|
429
|
+
"content": content,
|
430
|
+
"encoding": "utf-8",
|
431
|
+
}
|
312
432
|
except UnicodeDecodeError:
|
313
433
|
logger.warning(
|
314
434
|
f"UTF-8 decoding failed for file {file_id} ('{file_name}', {mime_type}). Using base64."
|
315
435
|
)
|
316
436
|
content = base64.b64encode(content_bytes).decode("utf-8")
|
317
437
|
return {
|
318
|
-
"
|
438
|
+
"file_id": file_id,
|
439
|
+
"name": file_name,
|
440
|
+
"mime_type": mime_type,
|
319
441
|
"content": content,
|
320
442
|
"encoding": "base64",
|
321
443
|
}
|
322
444
|
else:
|
323
445
|
# Binary file
|
324
446
|
content = base64.b64encode(content_bytes).decode("utf-8")
|
325
|
-
return {
|
447
|
+
return {
|
448
|
+
"file_id": file_id,
|
449
|
+
"name": file_name,
|
450
|
+
"mime_type": mime_type,
|
451
|
+
"content": content,
|
452
|
+
"encoding": "base64",
|
453
|
+
}
|
326
454
|
|
327
455
|
def _download_content(self, request) -> bytes:
|
328
456
|
"""Download content from a request."""
|
@@ -475,3 +603,98 @@ class DriveService(BaseGoogleService):
|
|
475
603
|
"message": str(e),
|
476
604
|
"operation": "list_shared_drives",
|
477
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
|
+
)
|