workspace-mcp 0.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.
gdrive/drive_tools.py ADDED
@@ -0,0 +1,362 @@
1
+ """
2
+ Google Drive MCP Tools
3
+
4
+ This module provides MCP tools for interacting with Google Drive API.
5
+ """
6
+ import logging
7
+ import asyncio
8
+ import re
9
+ from typing import List, Optional, Dict, Any
10
+
11
+ from mcp import types
12
+ from googleapiclient.errors import HttpError
13
+ from googleapiclient.http import MediaIoBaseDownload, MediaIoBaseUpload
14
+ import io
15
+
16
+ from auth.service_decorator import require_google_service
17
+ from core.utils import extract_office_xml_text
18
+ from core.server import server
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+ # Precompiled regex patterns for Drive query detection
23
+ DRIVE_QUERY_PATTERNS = [
24
+ re.compile(r'\b\w+\s*(=|!=|>|<)\s*[\'"].*?[\'"]', re.IGNORECASE), # field = 'value'
25
+ re.compile(r'\b\w+\s*(=|!=|>|<)\s*\d+', re.IGNORECASE), # field = number
26
+ re.compile(r'\bcontains\b', re.IGNORECASE), # contains operator
27
+ re.compile(r'\bin\s+parents\b', re.IGNORECASE), # in parents
28
+ re.compile(r'\bhas\s*\{', re.IGNORECASE), # has {properties}
29
+ re.compile(r'\btrashed\s*=\s*(true|false)\b', re.IGNORECASE), # trashed=true/false
30
+ re.compile(r'\bstarred\s*=\s*(true|false)\b', re.IGNORECASE), # starred=true/false
31
+ re.compile(r'[\'"][^\'"]+[\'"]\s+in\s+parents', re.IGNORECASE), # 'parentId' in parents
32
+ re.compile(r'\bfullText\s+contains\b', re.IGNORECASE), # fullText contains
33
+ re.compile(r'\bname\s*(=|contains)\b', re.IGNORECASE), # name = or name contains
34
+ re.compile(r'\bmimeType\s*(=|!=)\b', re.IGNORECASE), # mimeType operators
35
+ ]
36
+
37
+
38
+ def _build_drive_list_params(
39
+ query: str,
40
+ page_size: int,
41
+ drive_id: Optional[str] = None,
42
+ include_items_from_all_drives: bool = True,
43
+ corpora: Optional[str] = None,
44
+ ) -> Dict[str, Any]:
45
+ """
46
+ Helper function to build common list parameters for Drive API calls.
47
+
48
+ Args:
49
+ query: The search query string
50
+ page_size: Maximum number of items to return
51
+ drive_id: Optional shared drive ID
52
+ include_items_from_all_drives: Whether to include items from all drives
53
+ corpora: Optional corpus specification
54
+
55
+ Returns:
56
+ Dictionary of parameters for Drive API list calls
57
+ """
58
+ list_params = {
59
+ "q": query,
60
+ "pageSize": page_size,
61
+ "fields": "nextPageToken, files(id, name, mimeType, webViewLink, iconLink, modifiedTime, size)",
62
+ "supportsAllDrives": True,
63
+ "includeItemsFromAllDrives": include_items_from_all_drives,
64
+ }
65
+
66
+ if drive_id:
67
+ list_params["driveId"] = drive_id
68
+ if corpora:
69
+ list_params["corpora"] = corpora
70
+ else:
71
+ list_params["corpora"] = "drive"
72
+ elif corpora:
73
+ list_params["corpora"] = corpora
74
+
75
+ return list_params
76
+
77
+ @server.tool()
78
+ @require_google_service("drive", "drive_read")
79
+ async def search_drive_files(
80
+ service,
81
+ user_google_email: str,
82
+ query: str,
83
+ page_size: int = 10,
84
+ drive_id: Optional[str] = None,
85
+ include_items_from_all_drives: bool = True,
86
+ corpora: Optional[str] = None,
87
+ ) -> str:
88
+ """
89
+ Searches for files and folders within a user's Google Drive, including shared drives.
90
+
91
+ Args:
92
+ user_google_email (str): The user's Google email address. Required.
93
+ query (str): The search query string. Supports Google Drive search operators.
94
+ page_size (int): The maximum number of files to return. Defaults to 10.
95
+ drive_id (Optional[str]): ID of the shared drive to search. If None, behavior depends on `corpora` and `include_items_from_all_drives`.
96
+ include_items_from_all_drives (bool): Whether shared drive items should be included in results. Defaults to True. This is effective when not specifying a `drive_id`.
97
+ corpora (Optional[str]): Bodies of items to query (e.g., 'user', 'domain', 'drive', 'allDrives').
98
+ If 'drive_id' is specified and 'corpora' is None, it defaults to 'drive'.
99
+ Otherwise, Drive API default behavior applies. Prefer 'user' or 'drive' over 'allDrives' for efficiency.
100
+
101
+ Returns:
102
+ str: A formatted list of found files/folders with their details (ID, name, type, size, modified time, link).
103
+ """
104
+ logger.info(f"[search_drive_files] Invoked. Email: '{user_google_email}', Query: '{query}'")
105
+
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
130
+ )
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}")
149
+
150
+ @server.tool()
151
+ @require_google_service("drive", "drive_read")
152
+ async def get_drive_file_content(
153
+ service,
154
+ user_google_email: str,
155
+ file_id: str,
156
+ ) -> str:
157
+ """
158
+ Retrieves the content of a specific Google Drive file by ID, supporting files in shared drives.
159
+
160
+ • Native Google Docs, Sheets, Slides → exported as text / CSV.
161
+ • Office files (.docx, .xlsx, .pptx) → unzipped & parsed with std-lib to
162
+ extract readable text.
163
+ • Any other file → downloaded; tries UTF-8 decode, else notes binary.
164
+
165
+ Args:
166
+ user_google_email: The user’s Google email address.
167
+ file_id: Drive file ID.
168
+
169
+ Returns:
170
+ str: The file content as plain text with metadata header.
171
+ """
172
+ logger.info(f"[get_drive_file_content] Invoked. File ID: '{file_id}'")
173
+
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
+ )
222
+ else:
223
+ # For non-Office files (including Google native files), try UTF-8 decode directly
224
+ try:
225
+ body_text = file_content_bytes.decode("utf-8")
226
+ except UnicodeDecodeError:
227
+ body_text = (
228
+ f"[Binary or unsupported text encoding for mimeType '{mime_type}' - "
229
+ f"{len(file_content_bytes)} bytes]"
230
+ )
231
+
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}")
248
+
249
+
250
+ @require_google_service("drive", "drive_read")
251
+ async def list_drive_items(
252
+ service,
253
+ user_google_email: str,
254
+ folder_id: str = 'root',
255
+ page_size: int = 100,
256
+ drive_id: Optional[str] = None,
257
+ include_items_from_all_drives: bool = True,
258
+ corpora: Optional[str] = None,
259
+ ) -> str:
260
+ """
261
+ Lists files and folders, supporting shared drives.
262
+ If `drive_id` is specified, lists items within that shared drive. `folder_id` is then relative to that drive (or use drive_id as folder_id for root).
263
+ If `drive_id` is not specified, lists items from user's "My Drive" and accessible shared drives (if `include_items_from_all_drives` is True).
264
+
265
+ Args:
266
+ user_google_email (str): The user's Google email address. Required.
267
+ folder_id (str): The ID of the Google Drive folder. Defaults to 'root'. For a shared drive, this can be the shared drive's ID to list its root, or a folder ID within that shared drive.
268
+ page_size (int): The maximum number of items to return. Defaults to 100.
269
+ drive_id (Optional[str]): ID of the shared drive. If provided, the listing is scoped to this drive.
270
+ include_items_from_all_drives (bool): Whether items from all accessible shared drives should be included if `drive_id` is not set. Defaults to True.
271
+ corpora (Optional[str]): Corpus to query ('user', 'drive', 'allDrives'). If `drive_id` is set and `corpora` is None, 'drive' is used. If None and no `drive_id`, API defaults apply.
272
+
273
+ Returns:
274
+ str: A formatted list of files/folders in the specified folder.
275
+ """
276
+ logger.info(f"[list_drive_items] Invoked. Email: '{user_google_email}', Folder ID: '{folder_id}'")
277
+
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
291
+ )
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}")
310
+
311
+ @require_google_service("drive", "drive_file")
312
+ async def create_drive_file(
313
+ service,
314
+ user_google_email: str,
315
+ file_name: str,
316
+ content: str,
317
+ folder_id: str = 'root',
318
+ mime_type: str = 'text/plain',
319
+ ) -> str:
320
+ """
321
+ Creates a new file in Google Drive, supporting creation within shared drives.
322
+
323
+ Args:
324
+ user_google_email (str): The user's Google email address. Required.
325
+ file_name (str): The name for the new file.
326
+ content (str): The content to write to the file.
327
+ 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
+ mime_type (str): The MIME type of the file. Defaults to 'text/plain'.
329
+
330
+ Returns:
331
+ str: Confirmation message of the successful file creation with file link.
332
+ """
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}")
gforms/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """
2
+ Google Forms MCP Tools module
3
+ """
gforms/forms_tools.py ADDED
@@ -0,0 +1,318 @@
1
+ """
2
+ Google Forms MCP Tools
3
+
4
+ This module provides MCP tools for interacting with Google Forms API.
5
+ """
6
+
7
+ import logging
8
+ import asyncio
9
+ from typing import List, Optional, Dict, Any
10
+
11
+ from mcp import types
12
+ from googleapiclient.errors import HttpError
13
+
14
+ from auth.service_decorator import require_google_service
15
+ from core.server import server
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+
20
+ @server.tool()
21
+ @require_google_service("forms", "forms")
22
+ async def create_form(
23
+ service,
24
+ user_google_email: str,
25
+ title: str,
26
+ description: Optional[str] = None,
27
+ document_title: Optional[str] = None
28
+ ) -> str:
29
+ """
30
+ Create a new form using the title given in the provided form message in the request.
31
+
32
+ Args:
33
+ user_google_email (str): The user's Google email address. Required.
34
+ title (str): The title of the form.
35
+ description (Optional[str]): The description of the form.
36
+ document_title (Optional[str]): The document title (shown in browser tab).
37
+
38
+ Returns:
39
+ str: Confirmation message with form ID and edit URL.
40
+ """
41
+ logger.info(f"[create_form] Invoked. Email: '{user_google_email}', Title: {title}")
42
+
43
+ try:
44
+ form_body: Dict[str, Any] = {
45
+ "info": {
46
+ "title": title
47
+ }
48
+ }
49
+
50
+ if description:
51
+ form_body["info"]["description"] = description
52
+
53
+ if document_title:
54
+ form_body["info"]["document_title"] = document_title
55
+
56
+ created_form = await asyncio.to_thread(
57
+ service.forms().create(body=form_body).execute
58
+ )
59
+
60
+ form_id = created_form.get("formId")
61
+ edit_url = f"https://docs.google.com/forms/d/{form_id}/edit"
62
+ responder_url = created_form.get("responderUri", f"https://docs.google.com/forms/d/{form_id}/viewform")
63
+
64
+ confirmation_message = f"Successfully created form '{created_form.get('info', {}).get('title', title)}' for {user_google_email}. Form ID: {form_id}. Edit URL: {edit_url}. Responder URL: {responder_url}"
65
+ logger.info(f"Form created successfully for {user_google_email}. ID: {form_id}")
66
+ return confirmation_message
67
+ except HttpError as error:
68
+ message = f"API error creating form: {error}. You might need to re-authenticate. LLM: Try 'start_google_auth' with the user's email ({user_google_email}) and service_name='Google Forms'."
69
+ logger.error(message, exc_info=True)
70
+ raise Exception(message)
71
+ except Exception as e:
72
+ message = f"Unexpected error creating form: {e}."
73
+ logger.exception(message)
74
+ raise Exception(message)
75
+
76
+
77
+ @server.tool()
78
+ @require_google_service("forms", "forms")
79
+ async def get_form(
80
+ service,
81
+ user_google_email: str,
82
+ form_id: str
83
+ ) -> str:
84
+ """
85
+ Get a form.
86
+
87
+ Args:
88
+ user_google_email (str): The user's Google email address. Required.
89
+ form_id (str): The ID of the form to retrieve.
90
+
91
+ Returns:
92
+ str: Form details including title, description, questions, and URLs.
93
+ """
94
+ logger.info(f"[get_form] Invoked. Email: '{user_google_email}', Form ID: {form_id}")
95
+
96
+ try:
97
+ form = await asyncio.to_thread(
98
+ service.forms().get(formId=form_id).execute
99
+ )
100
+
101
+ form_info = form.get("info", {})
102
+ title = form_info.get("title", "No Title")
103
+ description = form_info.get("description", "No Description")
104
+ document_title = form_info.get("documentTitle", title)
105
+
106
+ edit_url = f"https://docs.google.com/forms/d/{form_id}/edit"
107
+ responder_url = form.get("responderUri", f"https://docs.google.com/forms/d/{form_id}/viewform")
108
+
109
+ items = form.get("items", [])
110
+ questions_summary = []
111
+ for i, item in enumerate(items, 1):
112
+ item_title = item.get("title", f"Question {i}")
113
+ item_type = item.get("questionItem", {}).get("question", {}).get("required", False)
114
+ required_text = " (Required)" if item_type else ""
115
+ questions_summary.append(f" {i}. {item_title}{required_text}")
116
+
117
+ questions_text = "\n".join(questions_summary) if questions_summary else " No questions found"
118
+
119
+ result = f"""Form Details for {user_google_email}:
120
+ - Title: "{title}"
121
+ - Description: "{description}"
122
+ - Document Title: "{document_title}"
123
+ - Form ID: {form_id}
124
+ - Edit URL: {edit_url}
125
+ - Responder URL: {responder_url}
126
+ - Questions ({len(items)} total):
127
+ {questions_text}"""
128
+
129
+ logger.info(f"Successfully retrieved form for {user_google_email}. ID: {form_id}")
130
+ return result
131
+ except HttpError as error:
132
+ message = f"API error getting form: {error}. You might need to re-authenticate. LLM: Try 'start_google_auth' with the user's email ({user_google_email}) and service_name='Google Forms'."
133
+ logger.error(message, exc_info=True)
134
+ raise Exception(message)
135
+ except Exception as e:
136
+ message = f"Unexpected error getting form: {e}."
137
+ logger.exception(message)
138
+ raise Exception(message)
139
+
140
+
141
+ @server.tool()
142
+ @require_google_service("forms", "forms")
143
+ async def set_publish_settings(
144
+ service,
145
+ user_google_email: str,
146
+ form_id: str,
147
+ publish_as_template: bool = False,
148
+ require_authentication: bool = False
149
+ ) -> str:
150
+ """
151
+ Updates the publish settings of a form.
152
+
153
+ Args:
154
+ user_google_email (str): The user's Google email address. Required.
155
+ form_id (str): The ID of the form to update publish settings for.
156
+ publish_as_template (bool): Whether to publish as a template. Defaults to False.
157
+ require_authentication (bool): Whether to require authentication to view/submit. Defaults to False.
158
+
159
+ Returns:
160
+ str: Confirmation message of the successful publish settings update.
161
+ """
162
+ logger.info(f"[set_publish_settings] Invoked. Email: '{user_google_email}', Form ID: {form_id}")
163
+
164
+ try:
165
+ settings_body = {
166
+ "publishAsTemplate": publish_as_template,
167
+ "requireAuthentication": require_authentication
168
+ }
169
+
170
+ await asyncio.to_thread(
171
+ service.forms().setPublishSettings(formId=form_id, body=settings_body).execute
172
+ )
173
+
174
+ confirmation_message = f"Successfully updated publish settings for form {form_id} for {user_google_email}. Publish as template: {publish_as_template}, Require authentication: {require_authentication}"
175
+ logger.info(f"Publish settings updated successfully for {user_google_email}. Form ID: {form_id}")
176
+ return confirmation_message
177
+ except HttpError as error:
178
+ message = f"API error updating publish settings: {error}. You might need to re-authenticate. LLM: Try 'start_google_auth' with the user's email ({user_google_email}) and service_name='Google Forms'."
179
+ logger.error(message, exc_info=True)
180
+ raise Exception(message)
181
+ except Exception as e:
182
+ message = f"Unexpected error updating publish settings: {e}."
183
+ logger.exception(message)
184
+ raise Exception(message)
185
+
186
+
187
+ @server.tool()
188
+ @require_google_service("forms", "forms")
189
+ async def get_form_response(
190
+ service,
191
+ user_google_email: str,
192
+ form_id: str,
193
+ response_id: str
194
+ ) -> str:
195
+ """
196
+ Get one response from the form.
197
+
198
+ Args:
199
+ user_google_email (str): The user's Google email address. Required.
200
+ form_id (str): The ID of the form.
201
+ response_id (str): The ID of the response to retrieve.
202
+
203
+ Returns:
204
+ str: Response details including answers and metadata.
205
+ """
206
+ logger.info(f"[get_form_response] Invoked. Email: '{user_google_email}', Form ID: {form_id}, Response ID: {response_id}")
207
+
208
+ try:
209
+ response = await asyncio.to_thread(
210
+ service.forms().responses().get(formId=form_id, responseId=response_id).execute
211
+ )
212
+
213
+ response_id = response.get("responseId", "Unknown")
214
+ create_time = response.get("createTime", "Unknown")
215
+ last_submitted_time = response.get("lastSubmittedTime", "Unknown")
216
+
217
+ answers = response.get("answers", {})
218
+ answer_details = []
219
+ for question_id, answer_data in answers.items():
220
+ question_response = answer_data.get("textAnswers", {}).get("answers", [])
221
+ if question_response:
222
+ answer_text = ", ".join([ans.get("value", "") for ans in question_response])
223
+ answer_details.append(f" Question ID {question_id}: {answer_text}")
224
+ else:
225
+ answer_details.append(f" Question ID {question_id}: No answer provided")
226
+
227
+ answers_text = "\n".join(answer_details) if answer_details else " No answers found"
228
+
229
+ result = f"""Form Response Details for {user_google_email}:
230
+ - Form ID: {form_id}
231
+ - Response ID: {response_id}
232
+ - Created: {create_time}
233
+ - Last Submitted: {last_submitted_time}
234
+ - Answers:
235
+ {answers_text}"""
236
+
237
+ logger.info(f"Successfully retrieved response for {user_google_email}. Response ID: {response_id}")
238
+ return result
239
+ except HttpError as error:
240
+ message = f"API error getting form response: {error}. You might need to re-authenticate. LLM: Try 'start_google_auth' with the user's email ({user_google_email}) and service_name='Google Forms'."
241
+ logger.error(message, exc_info=True)
242
+ raise Exception(message)
243
+ except Exception as e:
244
+ message = f"Unexpected error getting form response: {e}."
245
+ logger.exception(message)
246
+ raise Exception(message)
247
+
248
+
249
+ @server.tool()
250
+ @require_google_service("forms", "forms")
251
+ async def list_form_responses(
252
+ service,
253
+ user_google_email: str,
254
+ form_id: str,
255
+ page_size: int = 10,
256
+ page_token: Optional[str] = None
257
+ ) -> str:
258
+ """
259
+ List a form's responses.
260
+
261
+ Args:
262
+ user_google_email (str): The user's Google email address. Required.
263
+ form_id (str): The ID of the form.
264
+ page_size (int): Maximum number of responses to return. Defaults to 10.
265
+ page_token (Optional[str]): Token for retrieving next page of results.
266
+
267
+ Returns:
268
+ str: List of responses with basic details and pagination info.
269
+ """
270
+ logger.info(f"[list_form_responses] Invoked. Email: '{user_google_email}', Form ID: {form_id}")
271
+
272
+ try:
273
+ params = {
274
+ "formId": form_id,
275
+ "pageSize": page_size
276
+ }
277
+ if page_token:
278
+ params["pageToken"] = page_token
279
+
280
+ responses_result = await asyncio.to_thread(
281
+ service.forms().responses().list(**params).execute
282
+ )
283
+
284
+ responses = responses_result.get("responses", [])
285
+ next_page_token = responses_result.get("nextPageToken")
286
+
287
+ if not responses:
288
+ return f"No responses found for form {form_id} for {user_google_email}."
289
+
290
+ response_details = []
291
+ for i, response in enumerate(responses, 1):
292
+ response_id = response.get("responseId", "Unknown")
293
+ create_time = response.get("createTime", "Unknown")
294
+ last_submitted_time = response.get("lastSubmittedTime", "Unknown")
295
+
296
+ answers_count = len(response.get("answers", {}))
297
+ response_details.append(
298
+ f" {i}. Response ID: {response_id} | Created: {create_time} | Last Submitted: {last_submitted_time} | Answers: {answers_count}"
299
+ )
300
+
301
+ pagination_info = f"\nNext page token: {next_page_token}" if next_page_token else "\nNo more pages."
302
+
303
+ result = f"""Form Responses for {user_google_email}:
304
+ - Form ID: {form_id}
305
+ - Total responses returned: {len(responses)}
306
+ - Responses:
307
+ {chr(10).join(response_details)}{pagination_info}"""
308
+
309
+ logger.info(f"Successfully retrieved {len(responses)} responses for {user_google_email}. Form ID: {form_id}")
310
+ return result
311
+ except HttpError as error:
312
+ message = f"API error listing form responses: {error}. You might need to re-authenticate. LLM: Try 'start_google_auth' with the user's email ({user_google_email}) and service_name='Google Forms'."
313
+ logger.error(message, exc_info=True)
314
+ raise Exception(message)
315
+ except Exception as e:
316
+ message = f"Unexpected error listing form responses: {e}."
317
+ logger.exception(message)
318
+ raise Exception(message)
gmail/__init__.py ADDED
@@ -0,0 +1 @@
1
+ # This file marks the 'gmail' directory as a Python package.