workspace-mcp 1.0.1__py3-none-any.whl → 1.0.3__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.
gdrive/drive_tools.py CHANGED
@@ -12,9 +12,10 @@ from mcp import types
12
12
  from googleapiclient.errors import HttpError
13
13
  from googleapiclient.http import MediaIoBaseDownload, MediaIoBaseUpload
14
14
  import io
15
+ import httpx
15
16
 
16
17
  from auth.service_decorator import require_google_service
17
- from core.utils import extract_office_xml_text
18
+ from core.utils import extract_office_xml_text, handle_http_errors
18
19
  from core.server import server
19
20
 
20
21
  logger = logging.getLogger(__name__)
@@ -76,6 +77,7 @@ def _build_drive_list_params(
76
77
 
77
78
  @server.tool()
78
79
  @require_google_service("drive", "drive_read")
80
+ @handle_http_errors("search_drive_files")
79
81
  async def search_drive_files(
80
82
  service,
81
83
  user_google_email: str,
@@ -103,52 +105,46 @@ async def search_drive_files(
103
105
  """
104
106
  logger.info(f"[search_drive_files] Invoked. Email: '{user_google_email}', Query: '{query}'")
105
107
 
106
- try:
107
- # Check if the query looks like a structured Drive query or free text
108
- # Look for Drive API operators and structured query patterns
109
- is_structured_query = any(pattern.search(query) for pattern in DRIVE_QUERY_PATTERNS)
110
-
111
- if is_structured_query:
112
- final_query = query
113
- logger.info(f"[search_drive_files] Using structured query as-is: '{final_query}'")
114
- else:
115
- # For free text queries, wrap in fullText contains
116
- escaped_query = query.replace("'", "\\'")
117
- final_query = f"fullText contains '{escaped_query}'"
118
- logger.info(f"[search_drive_files] Reformatting free text query '{query}' to '{final_query}'")
119
-
120
- list_params = _build_drive_list_params(
121
- query=final_query,
122
- page_size=page_size,
123
- drive_id=drive_id,
124
- include_items_from_all_drives=include_items_from_all_drives,
125
- corpora=corpora,
126
- )
127
-
128
- results = await asyncio.to_thread(
129
- service.files().list(**list_params).execute
108
+ # Check if the query looks like a structured Drive query or free text
109
+ # Look for Drive API operators and structured query patterns
110
+ is_structured_query = any(pattern.search(query) for pattern in DRIVE_QUERY_PATTERNS)
111
+
112
+ if is_structured_query:
113
+ final_query = query
114
+ logger.info(f"[search_drive_files] Using structured query as-is: '{final_query}'")
115
+ else:
116
+ # For free text queries, wrap in fullText contains
117
+ escaped_query = query.replace("'", "\\'")
118
+ final_query = f"fullText contains '{escaped_query}'"
119
+ logger.info(f"[search_drive_files] Reformatting free text query '{query}' to '{final_query}'")
120
+
121
+ list_params = _build_drive_list_params(
122
+ query=final_query,
123
+ page_size=page_size,
124
+ drive_id=drive_id,
125
+ include_items_from_all_drives=include_items_from_all_drives,
126
+ corpora=corpora,
127
+ )
128
+
129
+ results = await asyncio.to_thread(
130
+ service.files().list(**list_params).execute
131
+ )
132
+ files = results.get('files', [])
133
+ if not files:
134
+ return f"No files found for '{query}'."
135
+
136
+ formatted_files_text_parts = [f"Found {len(files)} files for {user_google_email} matching '{query}':"]
137
+ for item in files:
138
+ size_str = f", Size: {item.get('size', 'N/A')}" if 'size' in item else ""
139
+ formatted_files_text_parts.append(
140
+ f"- Name: \"{item['name']}\" (ID: {item['id']}, Type: {item['mimeType']}{size_str}, Modified: {item.get('modifiedTime', 'N/A')}) Link: {item.get('webViewLink', '#')}"
130
141
  )
131
- files = results.get('files', [])
132
- if not files:
133
- return f"No files found for '{query}'."
134
-
135
- formatted_files_text_parts = [f"Found {len(files)} files for {user_google_email} matching '{query}':"]
136
- for item in files:
137
- size_str = f", Size: {item.get('size', 'N/A')}" if 'size' in item else ""
138
- formatted_files_text_parts.append(
139
- f"- Name: \"{item['name']}\" (ID: {item['id']}, Type: {item['mimeType']}{size_str}, Modified: {item.get('modifiedTime', 'N/A')}) Link: {item.get('webViewLink', '#')}"
140
- )
141
- text_output = "\n".join(formatted_files_text_parts)
142
- return text_output
143
- except HttpError as error:
144
- logger.error(f"API error searching Drive files: {error}", exc_info=True)
145
- raise Exception(f"API error: {error}")
146
- except Exception as e:
147
- logger.exception(f"Unexpected error searching Drive files: {e}")
148
- raise Exception(f"Unexpected error: {e}")
142
+ text_output = "\n".join(formatted_files_text_parts)
143
+ return text_output
149
144
 
150
145
  @server.tool()
151
146
  @require_google_service("drive", "drive_read")
147
+ @handle_http_errors("get_drive_file_content")
152
148
  async def get_drive_file_content(
153
149
  service,
154
150
  user_google_email: str,
@@ -171,56 +167,46 @@ async def get_drive_file_content(
171
167
  """
172
168
  logger.info(f"[get_drive_file_content] Invoked. File ID: '{file_id}'")
173
169
 
174
- try:
175
- file_metadata = await asyncio.to_thread(
176
- service.files().get(
177
- fileId=file_id, fields="id, name, mimeType, webViewLink", supportsAllDrives=True
178
- ).execute
179
- )
180
- mime_type = file_metadata.get("mimeType", "")
181
- file_name = file_metadata.get("name", "Unknown File")
182
- export_mime_type = {
183
- "application/vnd.google-apps.document": "text/plain",
184
- "application/vnd.google-apps.spreadsheet": "text/csv",
185
- "application/vnd.google-apps.presentation": "text/plain",
186
- }.get(mime_type)
187
-
188
- request_obj = (
189
- service.files().export_media(fileId=file_id, mimeType=export_mime_type)
190
- if export_mime_type
191
- else service.files().get_media(fileId=file_id)
192
- )
193
- fh = io.BytesIO()
194
- downloader = MediaIoBaseDownload(fh, request_obj)
195
- loop = asyncio.get_event_loop()
196
- done = False
197
- while not done:
198
- status, done = await loop.run_in_executor(None, downloader.next_chunk)
199
-
200
- file_content_bytes = fh.getvalue()
201
-
202
- # Attempt Office XML extraction only for actual Office XML files
203
- office_mime_types = {
204
- "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
205
- "application/vnd.openxmlformats-officedocument.presentationml.presentation",
206
- "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
207
- }
208
-
209
- if mime_type in office_mime_types:
210
- office_text = extract_office_xml_text(file_content_bytes, mime_type)
211
- if office_text:
212
- body_text = office_text
213
- else:
214
- # Fallback: try UTF-8; otherwise flag binary
215
- try:
216
- body_text = file_content_bytes.decode("utf-8")
217
- except UnicodeDecodeError:
218
- body_text = (
219
- f"[Binary or unsupported text encoding for mimeType '{mime_type}' - "
220
- f"{len(file_content_bytes)} bytes]"
221
- )
170
+ file_metadata = await asyncio.to_thread(
171
+ service.files().get(
172
+ fileId=file_id, fields="id, name, mimeType, webViewLink", supportsAllDrives=True
173
+ ).execute
174
+ )
175
+ mime_type = file_metadata.get("mimeType", "")
176
+ file_name = file_metadata.get("name", "Unknown File")
177
+ export_mime_type = {
178
+ "application/vnd.google-apps.document": "text/plain",
179
+ "application/vnd.google-apps.spreadsheet": "text/csv",
180
+ "application/vnd.google-apps.presentation": "text/plain",
181
+ }.get(mime_type)
182
+
183
+ request_obj = (
184
+ service.files().export_media(fileId=file_id, mimeType=export_mime_type)
185
+ if export_mime_type
186
+ else service.files().get_media(fileId=file_id)
187
+ )
188
+ fh = io.BytesIO()
189
+ downloader = MediaIoBaseDownload(fh, request_obj)
190
+ loop = asyncio.get_event_loop()
191
+ done = False
192
+ while not done:
193
+ status, done = await loop.run_in_executor(None, downloader.next_chunk)
194
+
195
+ file_content_bytes = fh.getvalue()
196
+
197
+ # Attempt Office XML extraction only for actual Office XML files
198
+ office_mime_types = {
199
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
200
+ "application/vnd.openxmlformats-officedocument.presentationml.presentation",
201
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
202
+ }
203
+
204
+ if mime_type in office_mime_types:
205
+ office_text = extract_office_xml_text(file_content_bytes, mime_type)
206
+ if office_text:
207
+ body_text = office_text
222
208
  else:
223
- # For non-Office files (including Google native files), try UTF-8 decode directly
209
+ # Fallback: try UTF-8; otherwise flag binary
224
210
  try:
225
211
  body_text = file_content_bytes.decode("utf-8")
226
212
  except UnicodeDecodeError:
@@ -228,26 +214,27 @@ async def get_drive_file_content(
228
214
  f"[Binary or unsupported text encoding for mimeType '{mime_type}' - "
229
215
  f"{len(file_content_bytes)} bytes]"
230
216
  )
217
+ else:
218
+ # For non-Office files (including Google native files), try UTF-8 decode directly
219
+ try:
220
+ body_text = file_content_bytes.decode("utf-8")
221
+ except UnicodeDecodeError:
222
+ body_text = (
223
+ f"[Binary or unsupported text encoding for mimeType '{mime_type}' - "
224
+ f"{len(file_content_bytes)} bytes]"
225
+ )
231
226
 
232
- # Assemble response
233
- header = (
234
- f'File: "{file_name}" (ID: {file_id}, Type: {mime_type})\n'
235
- f'Link: {file_metadata.get("webViewLink", "#")}\n\n--- CONTENT ---\n'
236
- )
237
- return header + body_text
238
-
239
- except HttpError as error:
240
- logger.error(
241
- f"API error getting Drive file content for {file_id}: {error}",
242
- exc_info=True,
243
- )
244
- raise Exception(f"API error: {error}")
245
- except Exception as e:
246
- logger.exception(f"Unexpected error getting Drive file content for {file_id}: {e}")
247
- raise Exception(f"Unexpected error: {e}")
227
+ # Assemble response
228
+ header = (
229
+ f'File: "{file_name}" (ID: {file_id}, Type: {mime_type})\n'
230
+ f'Link: {file_metadata.get("webViewLink", "#")}\n\n--- CONTENT ---\n'
231
+ )
232
+ return header + body_text
248
233
 
249
234
 
235
+ @server.tool()
250
236
  @require_google_service("drive", "drive_read")
237
+ @handle_http_errors("list_drive_items")
251
238
  async def list_drive_items(
252
239
  service,
253
240
  user_google_email: str,
@@ -275,88 +262,98 @@ async def list_drive_items(
275
262
  """
276
263
  logger.info(f"[list_drive_items] Invoked. Email: '{user_google_email}', Folder ID: '{folder_id}'")
277
264
 
278
- try:
279
- final_query = f"'{folder_id}' in parents and trashed=false"
280
-
281
- list_params = _build_drive_list_params(
282
- query=final_query,
283
- page_size=page_size,
284
- drive_id=drive_id,
285
- include_items_from_all_drives=include_items_from_all_drives,
286
- corpora=corpora,
287
- )
288
-
289
- results = await asyncio.to_thread(
290
- service.files().list(**list_params).execute
265
+ final_query = f"'{folder_id}' in parents and trashed=false"
266
+
267
+ list_params = _build_drive_list_params(
268
+ query=final_query,
269
+ page_size=page_size,
270
+ drive_id=drive_id,
271
+ include_items_from_all_drives=include_items_from_all_drives,
272
+ corpora=corpora,
273
+ )
274
+
275
+ results = await asyncio.to_thread(
276
+ service.files().list(**list_params).execute
277
+ )
278
+ files = results.get('files', [])
279
+ if not files:
280
+ return f"No items found in folder '{folder_id}'."
281
+
282
+ formatted_items_text_parts = [f"Found {len(files)} items in folder '{folder_id}' for {user_google_email}:"]
283
+ for item in files:
284
+ size_str = f", Size: {item.get('size', 'N/A')}" if 'size' in item else ""
285
+ formatted_items_text_parts.append(
286
+ f"- Name: \"{item['name']}\" (ID: {item['id']}, Type: {item['mimeType']}{size_str}, Modified: {item.get('modifiedTime', 'N/A')}) Link: {item.get('webViewLink', '#')}"
291
287
  )
292
- files = results.get('files', [])
293
- if not files:
294
- return f"No items found in folder '{folder_id}'."
295
-
296
- formatted_items_text_parts = [f"Found {len(files)} items in folder '{folder_id}' for {user_google_email}:"]
297
- for item in files:
298
- size_str = f", Size: {item.get('size', 'N/A')}" if 'size' in item else ""
299
- formatted_items_text_parts.append(
300
- f"- Name: \"{item['name']}\" (ID: {item['id']}, Type: {item['mimeType']}{size_str}, Modified: {item.get('modifiedTime', 'N/A')}) Link: {item.get('webViewLink', '#')}"
301
- )
302
- text_output = "\n".join(formatted_items_text_parts)
303
- return text_output
304
- except HttpError as error:
305
- logger.error(f"API error listing Drive items in folder {folder_id}: {error}", exc_info=True)
306
- raise Exception(f"API error: {error}")
307
- except Exception as e:
308
- logger.exception(f"Unexpected error listing Drive items in folder {folder_id}: {e}")
309
- raise Exception(f"Unexpected error: {e}")
288
+ text_output = "\n".join(formatted_items_text_parts)
289
+ return text_output
310
290
 
291
+ @server.tool()
311
292
  @require_google_service("drive", "drive_file")
293
+ @handle_http_errors("create_drive_file")
312
294
  async def create_drive_file(
313
295
  service,
314
296
  user_google_email: str,
315
297
  file_name: str,
316
- content: str,
298
+ content: Optional[str] = None, # Now explicitly Optional
317
299
  folder_id: str = 'root',
318
300
  mime_type: str = 'text/plain',
301
+ fileUrl: Optional[str] = None, # Now explicitly Optional
319
302
  ) -> str:
320
303
  """
321
304
  Creates a new file in Google Drive, supporting creation within shared drives.
305
+ Accepts either direct content or a fileUrl to fetch the content from.
322
306
 
323
307
  Args:
324
308
  user_google_email (str): The user's Google email address. Required.
325
309
  file_name (str): The name for the new file.
326
- content (str): The content to write to the file.
310
+ content (Optional[str]): If provided, the content to write to the file.
327
311
  folder_id (str): The ID of the parent folder. Defaults to 'root'. For shared drives, this must be a folder ID within the shared drive.
328
312
  mime_type (str): The MIME type of the file. Defaults to 'text/plain'.
313
+ fileUrl (Optional[str]): If provided, fetches the file content from this URL.
329
314
 
330
315
  Returns:
331
316
  str: Confirmation message of the successful file creation with file link.
332
317
  """
333
- logger.info(f"[create_drive_file] Invoked. Email: '{user_google_email}', File Name: {file_name}, Folder ID: {folder_id}")
334
-
335
- try:
336
- file_metadata = {
337
- 'name': file_name,
338
- 'parents': [folder_id],
339
- 'mimeType': mime_type
340
- }
341
- media = io.BytesIO(content.encode('utf-8'))
342
-
343
- created_file = await asyncio.to_thread(
344
- service.files().create(
345
- body=file_metadata,
346
- media_body=MediaIoBaseUpload(media, mimetype=mime_type, resumable=True),
347
- fields='id, name, webViewLink',
348
- supportsAllDrives=True
349
- ).execute
350
- )
351
-
352
- link = created_file.get('webViewLink', 'No link available')
353
- confirmation_message = f"Successfully created file '{created_file.get('name', file_name)}' (ID: {created_file.get('id', 'N/A')}) in folder '{folder_id}' for {user_google_email}. Link: {link}"
354
- logger.info(f"Successfully created file. Link: {link}")
355
- return confirmation_message
356
-
357
- except HttpError as error:
358
- logger.error(f"API error creating Drive file '{file_name}': {error}", exc_info=True)
359
- raise Exception(f"API error: {error}")
360
- except Exception as e:
361
- logger.exception(f"Unexpected error creating Drive file '{file_name}': {e}")
362
- raise Exception(f"Unexpected error: {e}")
318
+ logger.info(f"[create_drive_file] Invoked. Email: '{user_google_email}', File Name: {file_name}, Folder ID: {folder_id}, fileUrl: {fileUrl}")
319
+
320
+ if not content and not fileUrl:
321
+ raise Exception("You must provide either 'content' or 'fileUrl'.")
322
+
323
+ file_data = None
324
+ # Prefer fileUrl if both are provided
325
+ if fileUrl:
326
+ logger.info(f"[create_drive_file] Fetching file from URL: {fileUrl}")
327
+ async with httpx.AsyncClient() as client:
328
+ resp = await client.get(fileUrl)
329
+ if resp.status_code != 200:
330
+ raise Exception(f"Failed to fetch file from URL: {fileUrl} (status {resp.status_code})")
331
+ file_data = await resp.aread()
332
+ # Try to get MIME type from Content-Type header
333
+ content_type = resp.headers.get("Content-Type")
334
+ if content_type and content_type != "application/octet-stream":
335
+ mime_type = content_type
336
+ logger.info(f"[create_drive_file] Using MIME type from Content-Type header: {mime_type}")
337
+ elif content:
338
+ file_data = content.encode('utf-8')
339
+
340
+ file_metadata = {
341
+ 'name': file_name,
342
+ 'parents': [folder_id],
343
+ 'mimeType': mime_type
344
+ }
345
+ media = io.BytesIO(file_data)
346
+
347
+ created_file = await asyncio.to_thread(
348
+ service.files().create(
349
+ body=file_metadata,
350
+ media_body=MediaIoBaseUpload(media, mimetype=mime_type, resumable=True),
351
+ fields='id, name, webViewLink',
352
+ supportsAllDrives=True
353
+ ).execute
354
+ )
355
+
356
+ link = created_file.get('webViewLink', 'No link available')
357
+ confirmation_message = f"Successfully created file '{created_file.get('name', file_name)}' (ID: {created_file.get('id', 'N/A')}) in folder '{folder_id}' for {user_google_email}. Link: {link}"
358
+ logger.info(f"Successfully created file. Link: {link}")
359
+ return confirmation_message