workspace-mcp 1.0.1__py3-none-any.whl → 1.0.2__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.
- auth/service_decorator.py +31 -32
- core/utils.py +36 -0
- gcalendar/calendar_tools.py +308 -258
- gchat/chat_tools.py +131 -158
- gdocs/docs_tools.py +121 -149
- gdrive/drive_tools.py +168 -171
- gforms/forms_tools.py +118 -157
- gmail/gmail_tools.py +319 -400
- gsheets/sheets_tools.py +144 -197
- gslides/slides_tools.py +113 -157
- main.py +30 -24
- {workspace_mcp-1.0.1.dist-info → workspace_mcp-1.0.2.dist-info}/METADATA +6 -3
- {workspace_mcp-1.0.1.dist-info → workspace_mcp-1.0.2.dist-info}/RECORD +17 -17
- {workspace_mcp-1.0.1.dist-info → workspace_mcp-1.0.2.dist-info}/WHEEL +0 -0
- {workspace_mcp-1.0.1.dist-info → workspace_mcp-1.0.2.dist-info}/entry_points.txt +0 -0
- {workspace_mcp-1.0.1.dist-info → workspace_mcp-1.0.2.dist-info}/licenses/LICENSE +0 -0
- {workspace_mcp-1.0.1.dist-info → workspace_mcp-1.0.2.dist-info}/top_level.txt +0 -0
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
|
-
|
107
|
-
|
108
|
-
|
109
|
-
|
110
|
-
|
111
|
-
|
112
|
-
|
113
|
-
|
114
|
-
|
115
|
-
|
116
|
-
|
117
|
-
|
118
|
-
|
119
|
-
|
120
|
-
|
121
|
-
|
122
|
-
|
123
|
-
|
124
|
-
|
125
|
-
|
126
|
-
|
127
|
-
|
128
|
-
|
129
|
-
|
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
|
-
|
132
|
-
|
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
|
-
|
175
|
-
|
176
|
-
|
177
|
-
|
178
|
-
|
179
|
-
|
180
|
-
|
181
|
-
|
182
|
-
|
183
|
-
|
184
|
-
|
185
|
-
|
186
|
-
|
187
|
-
|
188
|
-
|
189
|
-
|
190
|
-
|
191
|
-
|
192
|
-
|
193
|
-
|
194
|
-
|
195
|
-
|
196
|
-
|
197
|
-
|
198
|
-
|
199
|
-
|
200
|
-
|
201
|
-
|
202
|
-
|
203
|
-
|
204
|
-
|
205
|
-
|
206
|
-
|
207
|
-
|
208
|
-
|
209
|
-
|
210
|
-
|
211
|
-
|
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
|
-
#
|
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
|
-
|
233
|
-
|
234
|
-
|
235
|
-
|
236
|
-
|
237
|
-
|
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
|
-
|
279
|
-
|
280
|
-
|
281
|
-
|
282
|
-
|
283
|
-
|
284
|
-
|
285
|
-
|
286
|
-
|
287
|
-
|
288
|
-
|
289
|
-
|
290
|
-
|
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
|
-
|
293
|
-
|
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):
|
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
|
-
|
336
|
-
|
337
|
-
|
338
|
-
|
339
|
-
|
340
|
-
|
341
|
-
|
342
|
-
|
343
|
-
|
344
|
-
|
345
|
-
|
346
|
-
|
347
|
-
|
348
|
-
|
349
|
-
|
350
|
-
|
351
|
-
|
352
|
-
|
353
|
-
|
354
|
-
|
355
|
-
|
356
|
-
|
357
|
-
|
358
|
-
|
359
|
-
|
360
|
-
|
361
|
-
|
362
|
-
|
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
|