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.
- auth/__init__.py +1 -0
- auth/google_auth.py +549 -0
- auth/oauth_callback_server.py +241 -0
- auth/oauth_responses.py +223 -0
- auth/scopes.py +108 -0
- auth/service_decorator.py +404 -0
- core/__init__.py +1 -0
- core/server.py +214 -0
- core/utils.py +162 -0
- gcalendar/__init__.py +1 -0
- gcalendar/calendar_tools.py +496 -0
- gchat/__init__.py +6 -0
- gchat/chat_tools.py +254 -0
- gdocs/__init__.py +0 -0
- gdocs/docs_tools.py +244 -0
- gdrive/__init__.py +0 -0
- gdrive/drive_tools.py +362 -0
- gforms/__init__.py +3 -0
- gforms/forms_tools.py +318 -0
- gmail/__init__.py +1 -0
- gmail/gmail_tools.py +807 -0
- gsheets/__init__.py +23 -0
- gsheets/sheets_tools.py +393 -0
- gslides/__init__.py +0 -0
- gslides/slides_tools.py +316 -0
- main.py +160 -0
- workspace_mcp-0.2.0.dist-info/METADATA +29 -0
- workspace_mcp-0.2.0.dist-info/RECORD +32 -0
- workspace_mcp-0.2.0.dist-info/WHEEL +5 -0
- workspace_mcp-0.2.0.dist-info/entry_points.txt +2 -0
- workspace_mcp-0.2.0.dist-info/licenses/LICENSE +21 -0
- workspace_mcp-0.2.0.dist-info/top_level.txt +11 -0
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
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.
|