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.
- google_workspace_mcp/models.py +486 -0
- google_workspace_mcp/services/calendar.py +14 -4
- google_workspace_mcp/services/drive.py +268 -18
- google_workspace_mcp/services/sheets_service.py +273 -35
- google_workspace_mcp/services/slides.py +242 -53
- google_workspace_mcp/tools/calendar.py +99 -88
- google_workspace_mcp/tools/docs_tools.py +67 -33
- google_workspace_mcp/tools/drive.py +288 -25
- google_workspace_mcp/tools/gmail.py +95 -39
- google_workspace_mcp/tools/sheets_tools.py +112 -46
- google_workspace_mcp/tools/slides.py +317 -46
- {google_workspace_mcp-1.0.5.dist-info → google_workspace_mcp-1.2.0.dist-info}/METADATA +4 -3
- {google_workspace_mcp-1.0.5.dist-info → google_workspace_mcp-1.2.0.dist-info}/RECORD +15 -14
- {google_workspace_mcp-1.0.5.dist-info → google_workspace_mcp-1.2.0.dist-info}/WHEEL +0 -0
- {google_workspace_mcp-1.0.5.dist-info → google_workspace_mcp-1.2.0.dist-info}/entry_points.txt +0 -0
@@ -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,
|
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},
|
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
|
-
#
|
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":
|
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
|
-
|
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
|
-
|
68
|
-
|
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
|
-
"
|
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
|
-
"
|
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
|
-
"
|
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 {
|
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
|
-
"
|
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 {
|
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
|
+
)
|