Skip to content

Commit 6464c3c

Browse files
Merge pull request #390 from ugoano/feature/forms-batch-update
feat(forms): Add batch_update_form tool for Google Forms API
2 parents 051c9d7 + 0ddc731 commit 6464c3c

7 files changed

Lines changed: 1369 additions & 1041 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -818,6 +818,7 @@ cp .env.oauth21 .env
818818
| `set_publish_settings` | Complete | Configure form settings |
819819
| `get_form_response` | Complete | Get individual responses |
820820
| `list_form_responses` | Extended | List all responses with pagination |
821+
| `batch_update_form` | Complete | Apply batch updates (questions, settings) |
821822

822823
</td>
823824
<td width="50%" valign="top">

README_NEW.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -141,7 +141,7 @@ export OAUTHLIB_INSECURE_TRANSPORT=1 # Development only
141141

142142
**Comments:** `read_presentation_comments`, `create_presentation_comment`, `reply_to_presentation_comment`, `resolve_presentation_comment`
143143

144-
### Google Forms (5 tools)
144+
### Google Forms (6 tools)
145145

146146
| Tool | Tier | Description |
147147
|------|------|-------------|
@@ -150,6 +150,7 @@ export OAUTHLIB_INSECURE_TRANSPORT=1 # Development only
150150
| `list_form_responses` | Extended | List responses with pagination |
151151
| `set_publish_settings` | Complete | Configure template and authentication settings |
152152
| `get_form_response` | Complete | Get individual response details |
153+
| `batch_update_form` | Complete | Execute batch updates to forms (questions, items, settings) |
153154

154155
### Google Tasks (12 tools)
155156

core/tool_tiers.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,7 @@ forms:
108108
complete:
109109
- set_publish_settings
110110
- get_form_response
111+
- batch_update_form
111112

112113
slides:
113114
core:

gforms/forms_tools.py

Lines changed: 95 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
import logging
88
import asyncio
9-
from typing import Optional, Dict, Any
9+
from typing import List, Optional, Dict, Any
1010

1111

1212
from auth.service_decorator import require_google_service
@@ -283,3 +283,97 @@ async def list_form_responses(
283283
f"Successfully retrieved {len(responses)} responses for {user_google_email}. Form ID: {form_id}"
284284
)
285285
return result
286+
287+
288+
# Internal implementation function for testing
289+
async def _batch_update_form_impl(
290+
service: Any,
291+
form_id: str,
292+
requests: List[Dict[str, Any]],
293+
) -> str:
294+
"""Internal implementation for batch_update_form.
295+
296+
Applies batch updates to a Google Form using the Forms API batchUpdate method.
297+
298+
Args:
299+
service: Google Forms API service client.
300+
form_id: The ID of the form to update.
301+
requests: List of update request dictionaries.
302+
303+
Returns:
304+
Formatted string with batch update results.
305+
"""
306+
body = {"requests": requests}
307+
308+
result = await asyncio.to_thread(
309+
service.forms().batchUpdate(formId=form_id, body=body).execute
310+
)
311+
312+
replies = result.get("replies", [])
313+
314+
confirmation_message = f"""Batch Update Completed:
315+
- Form ID: {form_id}
316+
- URL: https://docs.google.com/forms/d/{form_id}/edit
317+
- Requests Applied: {len(requests)}
318+
- Replies Received: {len(replies)}"""
319+
320+
if replies:
321+
confirmation_message += "\n\nUpdate Results:"
322+
for i, reply in enumerate(replies, 1):
323+
if "createItem" in reply:
324+
item_id = reply["createItem"].get("itemId", "Unknown")
325+
question_ids = reply["createItem"].get("questionId", [])
326+
question_info = (
327+
f" (Question IDs: {', '.join(question_ids)})"
328+
if question_ids
329+
else ""
330+
)
331+
confirmation_message += (
332+
f"\n Request {i}: Created item {item_id}{question_info}"
333+
)
334+
else:
335+
confirmation_message += f"\n Request {i}: Operation completed"
336+
337+
return confirmation_message
338+
339+
340+
@server.tool()
341+
@handle_http_errors("batch_update_form", service_type="forms")
342+
@require_google_service("forms", "forms")
343+
async def batch_update_form(
344+
service,
345+
user_google_email: str,
346+
form_id: str,
347+
requests: List[Dict[str, Any]],
348+
) -> str:
349+
"""
350+
Apply batch updates to a Google Form.
351+
352+
Supports adding, updating, and deleting form items, as well as updating
353+
form metadata and settings. This is the primary method for modifying form
354+
content after creation.
355+
356+
Args:
357+
user_google_email (str): The user's Google email address. Required.
358+
form_id (str): The ID of the form to update.
359+
requests (List[Dict[str, Any]]): List of update requests to apply.
360+
Supported request types:
361+
- createItem: Add a new question or content item
362+
- updateItem: Modify an existing item
363+
- deleteItem: Remove an item
364+
- moveItem: Reorder an item
365+
- updateFormInfo: Update form title/description
366+
- updateSettings: Modify form settings (e.g., quiz mode)
367+
368+
Returns:
369+
str: Details about the batch update operation results.
370+
"""
371+
logger.info(
372+
f"[batch_update_form] Invoked. Email: '{user_google_email}', "
373+
f"Form ID: '{form_id}', Requests: {len(requests)}"
374+
)
375+
376+
result = await _batch_update_form_impl(service, form_id, requests)
377+
378+
logger.info(f"Batch update completed successfully for {user_google_email}")
379+
return result

tests/gforms/__init__.py

Whitespace-only changes.

tests/gforms/test_forms_tools.py

Lines changed: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,231 @@
1+
"""
2+
Unit tests for Google Forms MCP tools
3+
4+
Tests the batch_update_form tool with mocked API responses
5+
"""
6+
7+
import pytest
8+
from unittest.mock import Mock
9+
import sys
10+
import os
11+
12+
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../..")))
13+
14+
# Import the internal implementation function (not the decorated one)
15+
from gforms.forms_tools import _batch_update_form_impl
16+
17+
18+
@pytest.mark.asyncio
19+
async def test_batch_update_form_multiple_requests():
20+
"""Test batch update with multiple requests returns formatted results"""
21+
mock_service = Mock()
22+
mock_response = {
23+
"replies": [
24+
{"createItem": {"itemId": "item001", "questionId": ["q001"]}},
25+
{"createItem": {"itemId": "item002", "questionId": ["q002"]}},
26+
],
27+
"writeControl": {"requiredRevisionId": "rev123"},
28+
}
29+
30+
mock_service.forms().batchUpdate().execute.return_value = mock_response
31+
32+
requests = [
33+
{
34+
"createItem": {
35+
"item": {
36+
"title": "What is your name?",
37+
"questionItem": {
38+
"question": {"textQuestion": {"paragraph": False}}
39+
},
40+
},
41+
"location": {"index": 0},
42+
}
43+
},
44+
{
45+
"createItem": {
46+
"item": {
47+
"title": "What is your email?",
48+
"questionItem": {
49+
"question": {"textQuestion": {"paragraph": False}}
50+
},
51+
},
52+
"location": {"index": 1},
53+
}
54+
},
55+
]
56+
57+
result = await _batch_update_form_impl(
58+
service=mock_service,
59+
form_id="test_form_123",
60+
requests=requests,
61+
)
62+
63+
assert "Batch Update Completed" in result
64+
assert "test_form_123" in result
65+
assert "Requests Applied: 2" in result
66+
assert "Replies Received: 2" in result
67+
assert "item001" in result
68+
assert "item002" in result
69+
70+
71+
@pytest.mark.asyncio
72+
async def test_batch_update_form_single_request():
73+
"""Test batch update with a single request"""
74+
mock_service = Mock()
75+
mock_response = {
76+
"replies": [
77+
{"createItem": {"itemId": "item001", "questionId": ["q001"]}},
78+
],
79+
}
80+
81+
mock_service.forms().batchUpdate().execute.return_value = mock_response
82+
83+
requests = [
84+
{
85+
"createItem": {
86+
"item": {
87+
"title": "Favourite colour?",
88+
"questionItem": {
89+
"question": {
90+
"choiceQuestion": {
91+
"type": "RADIO",
92+
"options": [
93+
{"value": "Red"},
94+
{"value": "Blue"},
95+
],
96+
}
97+
}
98+
},
99+
},
100+
"location": {"index": 0},
101+
}
102+
},
103+
]
104+
105+
result = await _batch_update_form_impl(
106+
service=mock_service,
107+
form_id="single_form_456",
108+
requests=requests,
109+
)
110+
111+
assert "single_form_456" in result
112+
assert "Requests Applied: 1" in result
113+
assert "Replies Received: 1" in result
114+
115+
116+
@pytest.mark.asyncio
117+
async def test_batch_update_form_empty_replies():
118+
"""Test batch update when API returns no replies"""
119+
mock_service = Mock()
120+
mock_response = {
121+
"replies": [],
122+
}
123+
124+
mock_service.forms().batchUpdate().execute.return_value = mock_response
125+
126+
requests = [
127+
{
128+
"updateFormInfo": {
129+
"info": {"description": "Updated description"},
130+
"updateMask": "description",
131+
}
132+
},
133+
]
134+
135+
result = await _batch_update_form_impl(
136+
service=mock_service,
137+
form_id="info_form_789",
138+
requests=requests,
139+
)
140+
141+
assert "info_form_789" in result
142+
assert "Requests Applied: 1" in result
143+
assert "Replies Received: 0" in result
144+
145+
146+
@pytest.mark.asyncio
147+
async def test_batch_update_form_no_replies_key():
148+
"""Test batch update when API response lacks replies key"""
149+
mock_service = Mock()
150+
mock_response = {}
151+
152+
mock_service.forms().batchUpdate().execute.return_value = mock_response
153+
154+
requests = [
155+
{
156+
"updateSettings": {
157+
"settings": {"quizSettings": {"isQuiz": True}},
158+
"updateMask": "quizSettings.isQuiz",
159+
}
160+
},
161+
]
162+
163+
result = await _batch_update_form_impl(
164+
service=mock_service,
165+
form_id="quiz_form_000",
166+
requests=requests,
167+
)
168+
169+
assert "quiz_form_000" in result
170+
assert "Requests Applied: 1" in result
171+
assert "Replies Received: 0" in result
172+
173+
174+
@pytest.mark.asyncio
175+
async def test_batch_update_form_url_in_response():
176+
"""Test that the edit URL is included in the response"""
177+
mock_service = Mock()
178+
mock_response = {
179+
"replies": [{}],
180+
}
181+
182+
mock_service.forms().batchUpdate().execute.return_value = mock_response
183+
184+
requests = [
185+
{"updateFormInfo": {"info": {"title": "New Title"}, "updateMask": "title"}}
186+
]
187+
188+
result = await _batch_update_form_impl(
189+
service=mock_service,
190+
form_id="url_form_abc",
191+
requests=requests,
192+
)
193+
194+
assert "https://docs.google.com/forms/d/url_form_abc/edit" in result
195+
196+
197+
@pytest.mark.asyncio
198+
async def test_batch_update_form_mixed_reply_types():
199+
"""Test batch update with createItem replies containing different fields"""
200+
mock_service = Mock()
201+
mock_response = {
202+
"replies": [
203+
{"createItem": {"itemId": "item_a", "questionId": ["qa"]}},
204+
{},
205+
{"createItem": {"itemId": "item_c"}},
206+
],
207+
}
208+
209+
mock_service.forms().batchUpdate().execute.return_value = mock_response
210+
211+
requests = [
212+
{"createItem": {"item": {"title": "Q1"}, "location": {"index": 0}}},
213+
{
214+
"updateFormInfo": {
215+
"info": {"description": "Desc"},
216+
"updateMask": "description",
217+
}
218+
},
219+
{"createItem": {"item": {"title": "Q2"}, "location": {"index": 1}}},
220+
]
221+
222+
result = await _batch_update_form_impl(
223+
service=mock_service,
224+
form_id="mixed_form_xyz",
225+
requests=requests,
226+
)
227+
228+
assert "Requests Applied: 3" in result
229+
assert "Replies Received: 3" in result
230+
assert "item_a" in result
231+
assert "item_c" in result

0 commit comments

Comments
 (0)