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.
@@ -6,6 +6,16 @@ import logging
6
6
  from typing import Any
7
7
 
8
8
  from google_workspace_mcp.app import mcp # Import from central app module
9
+ from google_workspace_mcp.models import (
10
+ DriveFileContentOutput,
11
+ DriveFileDeletionOutput,
12
+ DriveFileUploadOutput,
13
+ DriveFolderCreationOutput,
14
+ DriveFolderFindOutput,
15
+ DriveFolderSearchOutput,
16
+ DriveSearchOutput,
17
+ DriveSharedDrivesOutput,
18
+ )
9
19
  from google_workspace_mcp.services.drive import DriveService
10
20
 
11
21
  logger = logging.getLogger(__name__)
@@ -16,23 +26,50 @@ logger = logging.getLogger(__name__)
16
26
 
17
27
  @mcp.tool(
18
28
  name="drive_search_files",
19
- description="Search for files in Google Drive with optional shared drive support.",
29
+ description="""Search for files in Google Drive with intelligent query handling.
30
+
31
+ QUERY FORMATS SUPPORTED:
32
+ 1. Simple text searches (recommended): "sprint planning meeting notes" - searches content and filenames
33
+ 2. Drive API syntax: name contains 'project' AND modifiedTime > '2024-01-01'
34
+
35
+ CRITICAL LIMITATIONS:
36
+ - Parentheses ( ) are NOT supported in file searches
37
+ - Mixed syntax (text + operators) is not allowed
38
+ - For OR logic with multiple terms, use separate tool calls
39
+
40
+ VALID EXAMPLES:
41
+ ✅ "project documents 2024" (simple text)
42
+ ✅ name contains 'sprint' (API syntax)
43
+ ✅ fullText contains 'meeting' AND modifiedTime > '2024-01-01' (combined API syntax)
44
+ ✅ mimeType = 'application/pdf' (file type filter)
45
+
46
+ INVALID EXAMPLES (will cause errors):
47
+ ❌ "project (sprint OR planning)" - parentheses not supported
48
+ ❌ "ArcLio sprint OR planning" - mixed text and operators
49
+ ❌ "meeting modifiedTime > '2024-01-01'" - mixed syntax
50
+
51
+ COMMON FILE TYPE FILTERS:
52
+ - Google Docs: mimeType = 'application/vnd.google-apps.document'
53
+ - PDFs: mimeType = 'application/pdf'
54
+ - Images: mimeType contains 'image/'
55
+
56
+ DATE FORMAT: Use RFC 3339 format like '2024-01-01' for date searches.""",
20
57
  )
21
58
  async def drive_search_files(
22
59
  query: str,
23
60
  page_size: int = 10,
24
61
  shared_drive_id: str | None = None,
25
- ) -> dict[str, Any]:
62
+ ) -> DriveSearchOutput:
26
63
  """
27
- Search for files in Google Drive, optionally within a specific shared drive.
64
+ Search for files in Google Drive.
28
65
 
29
66
  Args:
30
- query: Search query string. Can be a simple text search or complex query with operators.
31
- page_size: Maximum number of files to return (1 to 1000, default 10).
32
- shared_drive_id: Optional shared drive ID to search within a specific shared drive.
67
+ query: Search query - either simple text or valid Drive API syntax (no parentheses)
68
+ page_size: Maximum number of files to return (1 to 1000, default 10)
69
+ shared_drive_id: Optional shared drive ID to search within specific shared drive
33
70
 
34
71
  Returns:
35
- A dictionary containing a list of files or an error message.
72
+ DriveSearchOutput containing a list of matching files with metadata
36
73
  """
37
74
  logger.info(
38
75
  f"Executing drive_search_files with query: '{query}', page_size: {page_size}, shared_drive_id: {shared_drive_id}"
@@ -49,14 +86,14 @@ async def drive_search_files(
49
86
  if isinstance(files, dict) and files.get("error"):
50
87
  raise ValueError(f"Search failed: {files.get('message', 'Unknown error')}")
51
88
 
52
- return {"files": files}
89
+ return DriveSearchOutput(files=files or [])
53
90
 
54
91
 
55
92
  @mcp.tool(
56
93
  name="drive_read_file_content",
57
94
  description="Read the content of a file from Google Drive.",
58
95
  )
59
- async def drive_read_file_content(file_id: str) -> dict[str, Any]:
96
+ async def drive_read_file_content(file_id: str) -> DriveFileContentOutput:
60
97
  """
61
98
  Read the content of a file from Google Drive.
62
99
 
@@ -64,7 +101,7 @@ async def drive_read_file_content(file_id: str) -> dict[str, Any]:
64
101
  file_id: The ID of the file to read.
65
102
 
66
103
  Returns:
67
- A dictionary containing the file content and metadata or an error.
104
+ DriveFileContentOutput containing the file content and metadata.
68
105
  """
69
106
  logger.info(f"Executing drive_read_file_content tool with file_id: '{file_id}'")
70
107
  if not file_id or not file_id.strip():
@@ -79,7 +116,12 @@ async def drive_read_file_content(file_id: str) -> dict[str, Any]:
79
116
  if isinstance(result, dict) and result.get("error"):
80
117
  raise ValueError(result.get("message", "Error reading file"))
81
118
 
82
- return result
119
+ return DriveFileContentOutput(
120
+ file_id=result["file_id"],
121
+ name=result["name"],
122
+ content=result["content"],
123
+ mime_type=result["mime_type"],
124
+ )
83
125
 
84
126
 
85
127
  @mcp.tool(
@@ -91,7 +133,7 @@ async def drive_upload_file(
91
133
  content_base64: str,
92
134
  parent_folder_id: str | None = None,
93
135
  shared_drive_id: str | None = None,
94
- ) -> dict[str, Any]:
136
+ ) -> DriveFileUploadOutput:
95
137
  """
96
138
  Uploads a file to Google Drive using its base64 encoded content.
97
139
 
@@ -102,7 +144,7 @@ async def drive_upload_file(
102
144
  shared_drive_id: Optional shared drive ID to upload the file to a shared drive.
103
145
 
104
146
  Returns:
105
- A dictionary containing the uploaded file metadata or an error.
147
+ DriveFileUploadOutput containing the uploaded file metadata.
106
148
  """
107
149
  logger.info(
108
150
  f"Executing drive_upload_file with filename: '{filename}', parent_folder_id: {parent_folder_id}, shared_drive_id: {shared_drive_id}"
@@ -123,7 +165,12 @@ async def drive_upload_file(
123
165
  if isinstance(result, dict) and result.get("error"):
124
166
  raise ValueError(result.get("message", "Error uploading file"))
125
167
 
126
- return result
168
+ return DriveFileUploadOutput(
169
+ id=result["id"],
170
+ name=result["name"],
171
+ web_view_link=result["web_view_link"],
172
+ size=result["size"],
173
+ )
127
174
 
128
175
 
129
176
  @mcp.tool(
@@ -134,7 +181,7 @@ async def drive_create_folder(
134
181
  folder_name: str,
135
182
  parent_folder_id: str | None = None,
136
183
  shared_drive_id: str | None = None,
137
- ) -> dict[str, Any]:
184
+ ) -> DriveFolderCreationOutput:
138
185
  """
139
186
  Create a new folder in Google Drive.
140
187
 
@@ -144,7 +191,7 @@ async def drive_create_folder(
144
191
  shared_drive_id: Optional shared drive ID to create the folder in a shared drive.
145
192
 
146
193
  Returns:
147
- A dictionary containing the created folder information.
194
+ DriveFolderCreationOutput containing the created folder information.
148
195
  """
149
196
  logger.info(
150
197
  f"Executing drive_create_folder with folder_name: '{folder_name}', parent_folder_id: {parent_folder_id}, shared_drive_id: {shared_drive_id}"
@@ -165,7 +212,9 @@ async def drive_create_folder(
165
212
  f"Folder creation failed: {result.get('message', 'Unknown error')}"
166
213
  )
167
214
 
168
- return result
215
+ return DriveFolderCreationOutput(
216
+ id=result["id"], name=result["name"], web_view_link=result["web_view_link"]
217
+ )
169
218
 
170
219
 
171
220
  @mcp.tool(
@@ -174,7 +223,7 @@ async def drive_create_folder(
174
223
  )
175
224
  async def drive_delete_file(
176
225
  file_id: str,
177
- ) -> dict[str, Any]:
226
+ ) -> DriveFileDeletionOutput:
178
227
  """
179
228
  Delete a file from Google Drive.
180
229
 
@@ -182,7 +231,7 @@ async def drive_delete_file(
182
231
  file_id: The ID of the file to delete.
183
232
 
184
233
  Returns:
185
- A dictionary confirming the deletion or an error.
234
+ DriveFileDeletionOutput confirming the deletion.
186
235
  """
187
236
  logger.info(f"Executing drive_delete_file with file_id: '{file_id}'")
188
237
  if not file_id or not file_id.strip():
@@ -194,14 +243,18 @@ async def drive_delete_file(
194
243
  if isinstance(result, dict) and result.get("error"):
195
244
  raise ValueError(result.get("message", "Error deleting file"))
196
245
 
197
- return result
246
+ return DriveFileDeletionOutput(
247
+ success=result.get("success", True),
248
+ message=result.get("message", f"File '{file_id}' deleted successfully"),
249
+ file_id=file_id,
250
+ )
198
251
 
199
252
 
200
253
  @mcp.tool(
201
254
  name="drive_list_shared_drives",
202
255
  description="Lists shared drives accessible by the user.",
203
256
  )
204
- async def drive_list_shared_drives(page_size: int = 100) -> dict[str, Any]:
257
+ async def drive_list_shared_drives(page_size: int = 100) -> DriveSharedDrivesOutput:
205
258
  """
206
259
  Lists shared drives (formerly Team Drives) that the user has access to.
207
260
 
@@ -209,8 +262,7 @@ async def drive_list_shared_drives(page_size: int = 100) -> dict[str, Any]:
209
262
  page_size: Maximum number of shared drives to return (1 to 100, default 100).
210
263
 
211
264
  Returns:
212
- A dictionary containing a list of shared drives with their 'id' and 'name',
213
- or an error message.
265
+ DriveSharedDrivesOutput containing a list of shared drives.
214
266
  """
215
267
  logger.info(f"Executing drive_list_shared_drives tool with page_size: {page_size}")
216
268
 
@@ -221,6 +273,217 @@ async def drive_list_shared_drives(page_size: int = 100) -> dict[str, Any]:
221
273
  raise ValueError(drives.get("message", "Error listing shared drives"))
222
274
 
223
275
  if not drives:
224
- return {"message": "No shared drives found or accessible."}
276
+ drives = []
277
+
278
+ return DriveSharedDrivesOutput(count=len(drives), shared_drives=drives)
279
+
280
+
281
+ @mcp.tool(
282
+ name="drive_search_files_in_folder",
283
+ )
284
+ async def drive_search_files_in_folder(
285
+ folder_id: str,
286
+ query: str = "",
287
+ page_size: int = 10,
288
+ ) -> DriveFolderSearchOutput:
289
+ """
290
+ Search for files or folders within a specific folder ID. Trashed files are excluded.
291
+ This works for both regular folders and Shared Drives (when using the Shared Drive's ID as the folder_id).
292
+
293
+ Args:
294
+ folder_id: The ID of the folder or Shared Drive to search within.
295
+ query: Optional search query string, following Google Drive API syntax.
296
+ If empty, returns all items.
297
+ Example to find only sub-folders: "mimeType = 'application/vnd.google-apps.folder'"
298
+ page_size: Maximum number of files to return (1 to 1000, default 10).
299
+
300
+ Returns:
301
+ DriveFolderSearchOutput containing a list of files and folders.
302
+ """
303
+ logger.info(
304
+ f"Executing drive_search_files_in_folder with folder_id: '{folder_id}', "
305
+ f"query: '{query}', page_size: {page_size}"
306
+ )
307
+
308
+ if not folder_id or not folder_id.strip():
309
+ raise ValueError("Folder ID cannot be empty")
310
+
311
+ # Build the search query to search within the specific folder
312
+ folder_query = f"'{folder_id}' in parents and trashed=false"
313
+ if query and query.strip():
314
+ # Automatically escape apostrophes in user query
315
+ escaped_query = query.strip().replace("'", "\\'")
316
+ # Combine folder constraint with user query
317
+ combined_query = f"{escaped_query} and {folder_query}"
318
+ else:
319
+ combined_query = folder_query
320
+
321
+ drive_service = DriveService()
322
+ files = drive_service.search_files(
323
+ query=combined_query,
324
+ page_size=page_size,
325
+ include_shared_drives=True, # Always include shared drives for folder searches
326
+ )
327
+
328
+ if isinstance(files, dict) and files.get("error"):
329
+ raise ValueError(
330
+ f"Folder search failed: {files.get('message', 'Unknown error')}"
331
+ )
332
+
333
+ return DriveFolderSearchOutput(folder_id=folder_id, files=files or [])
334
+
335
+
336
+ # @mcp.tool(
337
+ # name="drive_get_folder_info",
338
+ # )
339
+ async def drive_get_folder_info(folder_id: str) -> dict[str, Any]:
340
+ """
341
+ Get detailed information about a folder in Google Drive.
342
+
343
+ Useful for understanding folder permissions and hierarchy.
344
+
345
+ Args:
346
+ folder_id: The ID of the folder to get information about.
347
+
348
+ Returns:
349
+ A dictionary containing folder metadata or an error message.
350
+ """
351
+ logger.info(f"Executing drive_get_folder_info with folder_id: '{folder_id}'")
352
+
353
+ if not folder_id or not folder_id.strip():
354
+ raise ValueError("Folder ID cannot be empty")
355
+
356
+ drive_service = DriveService()
357
+ folder_info = drive_service.get_file_metadata(file_id=folder_id)
358
+
359
+ if isinstance(folder_info, dict) and folder_info.get("error"):
360
+ raise ValueError(
361
+ f"Failed to get folder info: {folder_info.get('message', 'Unknown error')}"
362
+ )
363
+
364
+ # Verify it's actually a folder
365
+ if folder_info.get("mimeType") != "application/vnd.google-apps.folder":
366
+ raise ValueError(
367
+ f"ID '{folder_id}' is not a folder (mimeType: {folder_info.get('mimeType')})"
368
+ )
369
+
370
+ return folder_info
371
+
372
+
373
+ @mcp.tool(
374
+ name="drive_find_folder_by_name",
375
+ )
376
+ async def drive_find_folder_by_name(
377
+ folder_name: str,
378
+ include_files: bool = False,
379
+ file_query: str = "",
380
+ page_size: int = 10,
381
+ shared_drive_id: str | None = None,
382
+ ) -> DriveFolderFindOutput:
383
+ """
384
+ Finds folders by name using a two-step search: first an exact match, then a partial match.
385
+ Automatically handles apostrophes in folder names and search queries. Trashed items are excluded.
386
+
387
+ Crucial Note: This tool finds **regular folders** within "My Drive" or a Shared Drive.
388
+ It **does not** find Shared Drives themselves. To list available Shared Drives,
389
+ use the `drive_list_shared_drives` tool.
390
+
391
+ Args:
392
+ folder_name: The name of the folder to search for.
393
+ include_files: Whether to also search for files within the found folder (default False).
394
+ file_query: Optional search query for files within the folder. Only used if include_files=True.
395
+ page_size: Maximum number of files to return (1 to 1000, default 10).
396
+ shared_drive_id: Optional shared drive ID to search within a specific shared drive.
397
+
398
+ Returns:
399
+ DriveFolderFindOutput containing folders found and optionally file search results.
400
+ """
401
+ logger.info(
402
+ f"Executing drive_find_folder_by_name with folder_name: '{folder_name}', "
403
+ f"include_files: {include_files}, file_query: '{file_query}', "
404
+ f"page_size: {page_size}, shared_drive_id: {shared_drive_id}"
405
+ )
406
+
407
+ if not folder_name or not folder_name.strip():
408
+ raise ValueError("Folder name cannot be empty")
225
409
 
226
- return {"count": len(drives), "shared_drives": drives}
410
+ drive_service = DriveService()
411
+ escaped_folder_name = folder_name.strip().replace("'", "\\'")
412
+
413
+ # --- Step 1: Attempt Exact Match ---
414
+ logger.info(f"Step 1: Searching for exact folder name: '{escaped_folder_name}'")
415
+ exact_query = f"name = '{escaped_folder_name}' and mimeType='application/vnd.google-apps.folder' and trashed=false"
416
+ folders = drive_service.search_files(
417
+ query=exact_query,
418
+ page_size=5,
419
+ shared_drive_id=shared_drive_id,
420
+ include_shared_drives=True,
421
+ )
422
+
423
+ # If no exact match, fall back to partial match
424
+ if not folders:
425
+ logger.info(
426
+ f"No exact match found. Step 2: Searching for folder name containing '{escaped_folder_name}'"
427
+ )
428
+ contains_query = f"name contains '{escaped_folder_name}' and mimeType='application/vnd.google-apps.folder' and trashed=false"
429
+ folders = drive_service.search_files(
430
+ query=contains_query,
431
+ page_size=5,
432
+ shared_drive_id=shared_drive_id,
433
+ include_shared_drives=True,
434
+ )
435
+
436
+ if isinstance(folders, dict) and folders.get("error"):
437
+ raise ValueError(
438
+ f"Folder search failed: {folders.get('message', 'Unknown error')}"
439
+ )
440
+
441
+ result = DriveFolderFindOutput(
442
+ folder_name=folder_name,
443
+ folders_found=folders or [],
444
+ folder_count=len(folders) if folders else 0,
445
+ )
446
+
447
+ if not include_files:
448
+ return result
449
+
450
+ if not folders:
451
+ result.message = f"No folders found with name matching '{folder_name}'"
452
+ return result
453
+
454
+ target_folder = folders[0]
455
+ folder_id = target_folder["id"]
456
+
457
+ # Build the search query for files within the folder
458
+ folder_constraint = f"'{folder_id}' in parents and trashed=false"
459
+
460
+ if file_query and file_query.strip():
461
+ # Use the same smart query logic as drive_search_files
462
+ clean_file_query = file_query.strip()
463
+ if (
464
+ " " not in clean_file_query
465
+ and ":" not in clean_file_query
466
+ and "=" not in clean_file_query
467
+ ):
468
+ escaped_file_query = clean_file_query.replace("'", "\\'")
469
+ wrapped_file_query = f"fullText contains '{escaped_file_query}'"
470
+ else:
471
+ wrapped_file_query = clean_file_query.replace("'", "\\'")
472
+ combined_query = f"{wrapped_file_query} and {folder_constraint}"
473
+ else:
474
+ combined_query = folder_constraint
475
+
476
+ files = drive_service.search_files(
477
+ query=combined_query, page_size=page_size, include_shared_drives=True
478
+ )
479
+
480
+ if isinstance(files, dict) and files.get("error"):
481
+ raise ValueError(
482
+ f"File search in folder failed: {files.get('message', 'Unknown error')}"
483
+ )
484
+
485
+ result.target_folder = target_folder
486
+ result.files = files or []
487
+ result.file_count = len(files) if files else 0
488
+
489
+ return result