diff --git a/README.md b/README.md
index c6146f1..7740390 100644
--- a/README.md
+++ b/README.md
@@ -64,11 +64,11 @@ A production-ready MCP server that integrates all major Google Workspace service
**@ Gmail** • **≡ Drive** • **⧖ Calendar** **≡ Docs**
-- Complete Gmail management, end to end coverage
+- Complete Gmail management, end-to-end coverage
- Full calendar management with advanced features
- File operations with Office format support
- Document creation, editing & comments
-- Deep, exhaustive support for fine grained editing
+- Deep, exhaustive support for fine-grained editing
---
@@ -846,9 +846,7 @@ cp .env.oauth21 .env
|------|------|-------------|
| `list_calendars` | **Core** | List accessible calendars |
| `get_events` | **Core** | Retrieve events with time range filtering |
-| `create_event` | **Core** | Create events with attachments & reminders |
-| `modify_event` | **Core** | Update existing events |
-| `delete_event` | Extended | Remove events |
+| `manage_event` | **Core** | Create, update, or delete calendar events |
|
@@ -863,15 +861,11 @@ cp .env.oauth21 .env
| `create_drive_file` | **Core** | Create files or fetch from URLs |
| `create_drive_folder` | **Core** | Create empty folders in Drive or shared drives |
| `import_to_google_doc` | **Core** | Import files (MD, DOCX, HTML, etc.) as Google Docs |
-| `share_drive_file` | **Core** | Share file with users/groups/domains/anyone |
| `get_drive_shareable_link` | **Core** | Get shareable links for a file |
| `list_drive_items` | Extended | List folder contents |
| `copy_drive_file` | Extended | Copy existing files (templates) with optional renaming |
| `update_drive_file` | Extended | Update file metadata, move between folders |
-| `batch_share_drive_file` | Extended | Share file with multiple recipients |
-| `update_drive_permission` | Extended | Modify permission role |
-| `remove_drive_permission` | Extended | Revoke file access |
-| `transfer_drive_ownership` | Extended | Transfer file ownership to another user |
+| `manage_drive_access` | Extended | Grant, update, revoke permissions, and transfer ownership |
| `set_drive_file_permissions` | Extended | Set link sharing and file-level sharing settings |
| `get_drive_file_permissions` | Complete | Get detailed file permissions |
| `check_drive_file_public_access` | Complete | Check public sharing status |
@@ -894,7 +888,9 @@ cp .env.oauth21 .env
| `get_gmail_thread_content` | Extended | Get full thread content |
| `modify_gmail_message_labels` | Extended | Modify message labels |
| `list_gmail_labels` | Extended | List available labels |
+| `list_gmail_filters` | Extended | List Gmail filters |
| `manage_gmail_label` | Extended | Create/update/delete labels |
+| `manage_gmail_filter` | Extended | Create or delete Gmail filters |
| `draft_gmail_message` | Extended | Create drafts |
| `get_gmail_threads_content_batch` | Complete | Batch retrieve thread content |
| `batch_modify_gmail_message_labels` | Complete | Batch modify labels |
@@ -965,7 +961,8 @@ Saved files expire after 1 hour and are cleaned up automatically.
| `export_doc_to_pdf` | Extended | Export document to PDF |
| `create_table_with_data` | Complete | Create data tables |
| `debug_table_structure` | Complete | Debug table issues |
-| `*_document_comments` | Complete | Read, Reply, Create, Resolve |
+| `list_document_comments` | Complete | List all document comments |
+| `manage_document_comment` | Complete | Create, reply to, or resolve comments |
|
@@ -984,7 +981,9 @@ Saved files expire after 1 hour and are cleaned up automatically.
| `get_spreadsheet_info` | Extended | Get spreadsheet metadata |
| `format_sheet_range` | Extended | Apply colors, number formats, text wrapping, alignment, bold/italic, font size |
| `create_sheet` | Complete | Add sheets to existing files |
-| `*_sheet_comment` | Complete | Read/create/reply/resolve comments |
+| `list_spreadsheet_comments` | Complete | List all spreadsheet comments |
+| `manage_spreadsheet_comment` | Complete | Create, reply to, or resolve comments |
+| `manage_conditional_formatting` | Complete | Add, update, or delete conditional formatting rules |
@@ -998,7 +997,8 @@ Saved files expire after 1 hour and are cleaned up automatically.
| `batch_update_presentation` | Extended | Apply multiple updates |
| `get_page` | Extended | Get specific slide information |
| `get_page_thumbnail` | Extended | Generate slide thumbnails |
-| `*_presentation_comment` | Complete | Read/create/reply/resolve comments |
+| `list_presentation_comments` | Complete | List all presentation comments |
+| `manage_presentation_comment` | Complete | Create, reply to, or resolve comments |
|
@@ -1025,12 +1025,10 @@ Saved files expire after 1 hour and are cleaned up automatically.
|------|------|-------------|
| `list_tasks` | **Core** | List tasks with filtering |
| `get_task` | **Core** | Retrieve task details |
-| `create_task` | **Core** | Create tasks with hierarchy |
-| `update_task` | **Core** | Modify task properties |
-| `delete_task` | Extended | Remove tasks |
-| `move_task` | Complete | Reposition tasks |
-| `clear_completed_tasks` | Complete | Hide completed tasks |
-| `*_task_list` | Complete | List/get/create/update/delete task lists |
+| `manage_task` | **Core** | Create, update, delete, or move tasks |
+| `list_task_lists` | Complete | List task lists |
+| `get_task_list` | Complete | Get task list details |
+| `manage_task_list` | Complete | Create, update, delete task lists, or clear completed tasks |
@@ -1044,14 +1042,11 @@ Saved files expire after 1 hour and are cleaned up automatically.
| `search_contacts` | **Core** | Search contacts by name, email, phone |
| `get_contact` | **Core** | Retrieve detailed contact info |
| `list_contacts` | **Core** | List contacts with pagination |
-| `create_contact` | **Core** | Create new contacts |
-| `update_contact` | Extended | Update existing contacts |
-| `delete_contact` | Extended | Delete contacts |
+| `manage_contact` | **Core** | Create, update, or delete contacts |
| `list_contact_groups` | Extended | List contact groups/labels |
| `get_contact_group` | Extended | Get group details with members |
-| `batch_*_contacts` | Complete | Batch create/update/delete contacts |
-| `*_contact_group` | Complete | Create/update/delete contact groups |
-| `modify_contact_group_members` | Complete | Add/remove contacts from groups |
+| `manage_contacts_batch` | Complete | Batch create, update, or delete contacts |
+| `manage_contact_group` | Complete | Create, update, delete groups, or modify membership |
@@ -1076,9 +1071,8 @@ Saved files expire after 1 hour and are cleaned up automatically.
| Tool | Tier | Description |
|------|------|-------------|
-| `search_custom` | **Core** | Perform web searches |
+| `search_custom` | **Core** | Perform web searches (supports site restrictions via sites parameter) |
| `get_search_engine_info` | Complete | Retrieve search engine metadata |
-| `search_custom_siterestrict` | Extended | Search within specific domains |
@@ -1095,10 +1089,8 @@ Saved files expire after 1 hour and are cleaned up automatically.
| `create_script_project` | **Core** | Create new standalone or bound project |
| `update_script_content` | **Core** | Update or create script files |
| `run_script_function` | **Core** | Execute function with parameters |
-| `create_deployment` | Extended | Create new script deployment |
| `list_deployments` | Extended | List all project deployments |
-| `update_deployment` | Extended | Update deployment configuration |
-| `delete_deployment` | Extended | Remove deployment |
+| `manage_deployment` | Extended | Create, update, or delete script deployments |
| `list_script_processes` | Extended | View recent executions and status |
diff --git a/README_NEW.md b/README_NEW.md
index 7debb00..9e01ba0 100644
--- a/README_NEW.md
+++ b/README_NEW.md
@@ -47,7 +47,7 @@ export OAUTHLIB_INSECURE_TRANSPORT=1 # Development only
## 🛠 Tools Reference
-### Gmail (11 tools)
+### Gmail (10 tools)
| Tool | Tier | Description |
|------|------|-------------|
@@ -60,39 +60,42 @@ export OAUTHLIB_INSECURE_TRANSPORT=1 # Development only
| `list_gmail_labels` | Extended | List all system and user labels |
| `manage_gmail_label` | Extended | Create, update, delete labels |
| `modify_gmail_message_labels` | Extended | Add/remove labels (archive, trash, etc.) |
+| `manage_gmail_filter` | Extended | Create or delete Gmail filters |
| `get_gmail_threads_content_batch` | Complete | Batch retrieve threads |
| `batch_modify_gmail_message_labels` | Complete | Bulk label operations |
-**Also includes:** `get_gmail_attachment_content`, `list_gmail_filters`, `create_gmail_filter`, `delete_gmail_filter`
+**Also includes:** `get_gmail_attachment_content`, `list_gmail_filters`
-### Google Drive (7 tools)
+### Google Drive (10 tools)
| Tool | Tier | Description |
|------|------|-------------|
| `search_drive_files` | Core | Search files with Drive query syntax or free text |
| `get_drive_file_content` | Core | Read content from Docs, Sheets, Office files (.docx, .xlsx, .pptx) |
+| `get_drive_file_download_url` | Core | Download Drive files to local disk |
| `create_drive_file` | Core | Create files from content or URL (supports file://, http://, https://) |
| `create_drive_folder` | Core | Create empty folders in Drive or shared drives |
+| `import_to_google_doc` | Core | Import files (MD, DOCX, HTML, etc.) as Google Docs |
+| `get_drive_shareable_link` | Core | Get shareable links for a file |
| `list_drive_items` | Extended | List folder contents with shared drive support |
+| `copy_drive_file` | Extended | Copy existing files (templates) with optional renaming |
| `update_drive_file` | Extended | Update metadata, move between folders, star, trash |
-| `get_drive_file_permissions` | Complete | Check sharing status and permissions |
+| `manage_drive_access` | Extended | Grant, update, revoke permissions, and transfer ownership |
+| `set_drive_file_permissions` | Extended | Set link sharing and file-level sharing settings |
+| `get_drive_file_permissions` | Complete | Get detailed file permissions |
| `check_drive_file_public_access` | Complete | Verify public link sharing for Docs image insertion |
-**Also includes:** `get_drive_file_download_url` for generating download URLs
-
-### Google Calendar (5 tools)
+### Google Calendar (3 tools)
| Tool | Tier | Description |
|------|------|-------------|
| `list_calendars` | Core | List all accessible calendars |
| `get_events` | Core | Query events by time range, search, or specific ID |
-| `create_event` | Core | Create events with attendees, reminders, Google Meet, attachments |
-| `modify_event` | Core | Update any event property including conferencing |
-| `delete_event` | Extended | Remove events |
+| `manage_event` | Core | Create, update, or delete calendar events |
-**Event features:** Timezone support, transparency (busy/free), visibility settings, up to 5 custom reminders
+**Event features:** Timezone support, transparency (busy/free), visibility settings, up to 5 custom reminders, Google Meet integration, attendees, attachments
-### Google Docs (16 tools)
+### Google Docs (14 tools)
| Tool | Tier | Description |
|------|------|-------------|
@@ -103,6 +106,8 @@ export OAUTHLIB_INSECURE_TRANSPORT=1 # Development only
| `find_and_replace_doc` | Extended | Global find/replace with case matching |
| `list_docs_in_folder` | Extended | List Docs in a specific folder |
| `insert_doc_elements` | Extended | Add tables, lists, page breaks |
+| `update_paragraph_style` | Extended | Apply heading styles, lists (bulleted/numbered with nesting), and paragraph formatting |
+| `get_doc_as_markdown` | Extended | Export document as formatted Markdown with optional comments |
| `export_doc_to_pdf` | Extended | Export to PDF and save to Drive |
| `insert_doc_image` | Complete | Insert images from Drive or URLs |
| `update_doc_headers_footers` | Complete | Modify headers/footers |
@@ -110,10 +115,10 @@ export OAUTHLIB_INSECURE_TRANSPORT=1 # Development only
| `inspect_doc_structure` | Complete | Analyze document structure for safe insertion points |
| `create_table_with_data` | Complete | Create and populate tables in one operation |
| `debug_table_structure` | Complete | Debug table cell positions and content |
+| `list_document_comments` | Complete | List all document comments |
+| `manage_document_comment` | Complete | Create, reply to, or resolve comments |
-**Comments:** `read_document_comments`, `create_document_comment`, `reply_to_document_comment`, `resolve_document_comment`
-
-### Google Sheets (13 tools)
+### Google Sheets (9 tools)
| Tool | Tier | Description |
|------|------|-------------|
@@ -124,13 +129,11 @@ export OAUTHLIB_INSECURE_TRANSPORT=1 # Development only
| `get_spreadsheet_info` | Extended | Get metadata, sheets, conditional formats |
| `format_sheet_range` | Extended | Apply colors, number formats, text wrapping, alignment, bold/italic, font size |
| `create_sheet` | Complete | Add sheets to existing spreadsheets |
-| `add_conditional_formatting` | Complete | Add boolean or gradient rules |
-| `update_conditional_formatting` | Complete | Modify existing rules |
-| `delete_conditional_formatting` | Complete | Remove formatting rules |
+| `list_spreadsheet_comments` | Complete | List all spreadsheet comments |
+| `manage_spreadsheet_comment` | Complete | Create, reply to, or resolve comments |
+| `manage_conditional_formatting` | Complete | Add, update, or delete conditional formatting rules |
-**Comments:** `read_spreadsheet_comments`, `create_spreadsheet_comment`, `reply_to_spreadsheet_comment`, `resolve_spreadsheet_comment`
-
-### Google Slides (9 tools)
+### Google Slides (7 tools)
| Tool | Tier | Description |
|------|------|-------------|
@@ -139,8 +142,8 @@ export OAUTHLIB_INSECURE_TRANSPORT=1 # Development only
| `batch_update_presentation` | Extended | Apply multiple updates (create slides, shapes, etc.) |
| `get_page` | Extended | Get specific slide details and elements |
| `get_page_thumbnail` | Extended | Generate PNG thumbnails |
-
-**Comments:** `read_presentation_comments`, `create_presentation_comment`, `reply_to_presentation_comment`, `resolve_presentation_comment`
+| `list_presentation_comments` | Complete | List all presentation comments |
+| `manage_presentation_comment` | Complete | Create, reply to, or resolve comments |
### Google Forms (6 tools)
@@ -153,24 +156,18 @@ export OAUTHLIB_INSECURE_TRANSPORT=1 # Development only
| `get_form_response` | Complete | Get individual response details |
| `batch_update_form` | Complete | Execute batch updates to forms (questions, items, settings) |
-### Google Tasks (12 tools)
+### Google Tasks (5 tools)
| Tool | Tier | Description |
|------|------|-------------|
| `list_tasks` | Core | List tasks with filtering, subtask hierarchy preserved |
| `get_task` | Core | Get task details |
-| `create_task` | Core | Create tasks with notes, due dates, parent/sibling positioning |
-| `update_task` | Core | Update task properties |
-| `delete_task` | Extended | Remove tasks |
+| `manage_task` | Core | Create, update, delete, or move tasks |
| `list_task_lists` | Complete | List all task lists |
| `get_task_list` | Complete | Get task list details |
-| `create_task_list` | Complete | Create new task lists |
-| `update_task_list` | Complete | Rename task lists |
-| `delete_task_list` | Complete | Delete task lists (and all tasks) |
-| `move_task` | Complete | Reposition or move between lists |
-| `clear_completed_tasks` | Complete | Hide completed tasks |
+| `manage_task_list` | Complete | Create, update, delete task lists, or clear completed tasks |
-### Google Apps Script (11 tools)
+### Google Apps Script (9 tools)
| Tool | Tier | Description |
|------|------|-------------|
@@ -180,16 +177,27 @@ export OAUTHLIB_INSECURE_TRANSPORT=1 # Development only
| `create_script_project` | Core | Create new standalone or bound project |
| `update_script_content` | Core | Update or create script files |
| `run_script_function` | Core | Execute function with parameters |
-| `create_deployment` | Extended | Create new script deployment |
| `list_deployments` | Extended | List all project deployments |
-| `update_deployment` | Extended | Update deployment configuration |
-| `delete_deployment` | Extended | Remove deployment |
+| `manage_deployment` | Extended | Create, update, or delete script deployments |
| `list_script_processes` | Extended | View recent executions and status |
**Enables:** Cross-app automation, persistent workflows, custom business logic execution, script development and debugging
**Note:** Trigger management is not currently supported via MCP tools.
+### Google Contacts (7 tools)
+
+| Tool | Tier | Description |
+|------|------|-------------|
+| `search_contacts` | Core | Search contacts by name, email, phone |
+| `get_contact` | Core | Retrieve detailed contact info |
+| `list_contacts` | Core | List contacts with pagination |
+| `manage_contact` | Core | Create, update, or delete contacts |
+| `list_contact_groups` | Extended | List contact groups/labels |
+| `get_contact_group` | Extended | Get group details with members |
+| `manage_contacts_batch` | Complete | Batch create, update, or delete contacts |
+| `manage_contact_group` | Complete | Create, update, delete groups, or modify membership |
+
### Google Chat (4 tools)
| Tool | Tier | Description |
@@ -199,12 +207,11 @@ export OAUTHLIB_INSECURE_TRANSPORT=1 # Development only
| `search_messages` | Core | Search across chat history |
| `list_spaces` | Extended | List rooms and DMs |
-### Google Custom Search (3 tools)
+### Google Custom Search (2 tools)
| Tool | Tier | Description |
|------|------|-------------|
-| `search_custom` | Core | Web search with filters (date, file type, language, safe search) |
-| `search_custom_siterestrict` | Extended | Search within specific domains |
+| `search_custom` | Core | Web search with filters (date, file type, language, safe search, site restrictions via sites parameter) |
| `get_search_engine_info` | Complete | Get search engine metadata |
**Requires:** `GOOGLE_PSE_API_KEY` and `GOOGLE_PSE_ENGINE_ID` environment variables
@@ -219,7 +226,7 @@ Choose a tier based on your needs:
|------|-------|----------|
| **Core** | ~30 | Essential operations: search, read, create, send |
| **Extended** | ~50 | Core + management: labels, folders, batch ops |
-| **Complete** | ~80 | Full API: comments, headers, admin functions |
+| **Complete** | 111 | Full API: comments, headers, admin functions |
```bash
uvx workspace-mcp --tool-tier core # Start minimal
diff --git a/auth/google_auth.py b/auth/google_auth.py
index 942d2f5..93e438e 100644
--- a/auth/google_auth.py
+++ b/auth/google_auth.py
@@ -295,6 +295,7 @@ def create_oauth_flow(
redirect_uri: str,
state: Optional[str] = None,
code_verifier: Optional[str] = None,
+ autogenerate_code_verifier: bool = True,
) -> Flow:
"""Creates an OAuth flow using environment variables or client secrets file."""
flow_kwargs = {
@@ -306,6 +307,12 @@ def create_oauth_flow(
flow_kwargs["code_verifier"] = code_verifier
# Preserve the original verifier when re-creating the flow in callback.
flow_kwargs["autogenerate_code_verifier"] = False
+ else:
+ # Generate PKCE code verifier for the initial auth flow.
+ # google-auth-oauthlib's from_client_* helpers pass
+ # autogenerate_code_verifier=None unless explicitly provided, which
+ # prevents Flow from generating and storing a code_verifier.
+ flow_kwargs["autogenerate_code_verifier"] = autogenerate_code_verifier
# Try environment variables first
env_config = load_client_secrets_from_env()
@@ -520,6 +527,7 @@ def handle_auth_callback(
redirect_uri=redirect_uri,
state=state,
code_verifier=state_info.get("code_verifier"),
+ autogenerate_code_verifier=False,
)
# Exchange the authorization code for credentials
diff --git a/core/comments.py b/core/comments.py
index c07d878..844bdda 100644
--- a/core/comments.py
+++ b/core/comments.py
@@ -7,7 +7,7 @@ All Google Workspace apps (Docs, Sheets, Slides) use the Drive API for comment o
import logging
import asyncio
-
+from typing import Optional
from auth.service_decorator import require_google_service
from core.server import server
@@ -16,6 +16,38 @@ from core.utils import handle_http_errors
logger = logging.getLogger(__name__)
+async def _manage_comment_dispatch(
+ service,
+ app_name: str,
+ file_id: str,
+ action: str,
+ comment_content: Optional[str] = None,
+ comment_id: Optional[str] = None,
+) -> str:
+ """Route comment management actions to the appropriate implementation."""
+ action_lower = action.lower().strip()
+ if action_lower == "create":
+ if not comment_content:
+ raise ValueError("comment_content is required for create action")
+ return await _create_comment_impl(service, app_name, file_id, comment_content)
+ elif action_lower == "reply":
+ if not comment_id or not comment_content:
+ raise ValueError(
+ "comment_id and comment_content are required for reply action"
+ )
+ return await _reply_to_comment_impl(
+ service, app_name, file_id, comment_id, comment_content
+ )
+ elif action_lower == "resolve":
+ if not comment_id:
+ raise ValueError("comment_id is required for resolve action")
+ return await _resolve_comment_impl(service, app_name, file_id, comment_id)
+ else:
+ raise ValueError(
+ f"Invalid action '{action_lower}'. Must be 'create', 'reply', or 'resolve'."
+ )
+
+
def create_comment_tools(app_name: str, file_id_param: str):
"""
Factory function to create comment management tools for a specific Google Workspace app.
@@ -25,165 +57,114 @@ def create_comment_tools(app_name: str, file_id_param: str):
file_id_param: Parameter name for the file ID (e.g., "document_id", "spreadsheet_id", "presentation_id")
Returns:
- Dict containing the four comment management functions with unique names
+ Dict containing the comment management functions with unique names
"""
- # Create unique function names based on the app type
- read_func_name = f"read_{app_name}_comments"
- create_func_name = f"create_{app_name}_comment"
- reply_func_name = f"reply_to_{app_name}_comment"
- resolve_func_name = f"resolve_{app_name}_comment"
+ # --- Consolidated tools ---
+ list_func_name = f"list_{app_name}_comments"
+ manage_func_name = f"manage_{app_name}_comment"
- # Create functions without decorators first, then apply decorators with proper names
if file_id_param == "document_id":
@require_google_service("drive", "drive_read")
- @handle_http_errors(read_func_name, service_type="drive")
- async def read_comments(
+ @handle_http_errors(list_func_name, service_type="drive")
+ async def list_comments(
service, user_google_email: str, document_id: str
) -> str:
- """Read all comments from a Google Document."""
+ """List all comments from a Google Document."""
return await _read_comments_impl(service, app_name, document_id)
@require_google_service("drive", "drive_file")
- @handle_http_errors(create_func_name, service_type="drive")
- async def create_comment(
- service, user_google_email: str, document_id: str, comment_content: str
- ) -> str:
- """Create a new comment on a Google Document."""
- return await _create_comment_impl(
- service, app_name, document_id, comment_content
- )
-
- @require_google_service("drive", "drive_file")
- @handle_http_errors(reply_func_name, service_type="drive")
- async def reply_to_comment(
+ @handle_http_errors(manage_func_name, service_type="drive")
+ async def manage_comment(
service,
user_google_email: str,
document_id: str,
- comment_id: str,
- reply_content: str,
+ action: str,
+ comment_content: Optional[str] = None,
+ comment_id: Optional[str] = None,
) -> str:
- """Reply to a specific comment in a Google Document."""
- return await _reply_to_comment_impl(
- service, app_name, document_id, comment_id, reply_content
- )
+ """Manage comments on a Google Document.
- @require_google_service("drive", "drive_file")
- @handle_http_errors(resolve_func_name, service_type="drive")
- async def resolve_comment(
- service, user_google_email: str, document_id: str, comment_id: str
- ) -> str:
- """Resolve a comment in a Google Document."""
- return await _resolve_comment_impl(
- service, app_name, document_id, comment_id
+ Actions:
+ - create: Create a new comment. Requires comment_content.
+ - reply: Reply to a comment. Requires comment_id and comment_content.
+ - resolve: Resolve a comment. Requires comment_id.
+ """
+ return await _manage_comment_dispatch(
+ service, app_name, document_id, action, comment_content, comment_id
)
elif file_id_param == "spreadsheet_id":
@require_google_service("drive", "drive_read")
- @handle_http_errors(read_func_name, service_type="drive")
- async def read_comments(
+ @handle_http_errors(list_func_name, service_type="drive")
+ async def list_comments(
service, user_google_email: str, spreadsheet_id: str
) -> str:
- """Read all comments from a Google Spreadsheet."""
+ """List all comments from a Google Spreadsheet."""
return await _read_comments_impl(service, app_name, spreadsheet_id)
@require_google_service("drive", "drive_file")
- @handle_http_errors(create_func_name, service_type="drive")
- async def create_comment(
- service, user_google_email: str, spreadsheet_id: str, comment_content: str
- ) -> str:
- """Create a new comment on a Google Spreadsheet."""
- return await _create_comment_impl(
- service, app_name, spreadsheet_id, comment_content
- )
-
- @require_google_service("drive", "drive_file")
- @handle_http_errors(reply_func_name, service_type="drive")
- async def reply_to_comment(
+ @handle_http_errors(manage_func_name, service_type="drive")
+ async def manage_comment(
service,
user_google_email: str,
spreadsheet_id: str,
- comment_id: str,
- reply_content: str,
+ action: str,
+ comment_content: Optional[str] = None,
+ comment_id: Optional[str] = None,
) -> str:
- """Reply to a specific comment in a Google Spreadsheet."""
- return await _reply_to_comment_impl(
- service, app_name, spreadsheet_id, comment_id, reply_content
- )
+ """Manage comments on a Google Spreadsheet.
- @require_google_service("drive", "drive_file")
- @handle_http_errors(resolve_func_name, service_type="drive")
- async def resolve_comment(
- service, user_google_email: str, spreadsheet_id: str, comment_id: str
- ) -> str:
- """Resolve a comment in a Google Spreadsheet."""
- return await _resolve_comment_impl(
- service, app_name, spreadsheet_id, comment_id
+ Actions:
+ - create: Create a new comment. Requires comment_content.
+ - reply: Reply to a comment. Requires comment_id and comment_content.
+ - resolve: Resolve a comment. Requires comment_id.
+ """
+ return await _manage_comment_dispatch(
+ service, app_name, spreadsheet_id, action, comment_content, comment_id
)
elif file_id_param == "presentation_id":
@require_google_service("drive", "drive_read")
- @handle_http_errors(read_func_name, service_type="drive")
- async def read_comments(
+ @handle_http_errors(list_func_name, service_type="drive")
+ async def list_comments(
service, user_google_email: str, presentation_id: str
) -> str:
- """Read all comments from a Google Presentation."""
+ """List all comments from a Google Presentation."""
return await _read_comments_impl(service, app_name, presentation_id)
@require_google_service("drive", "drive_file")
- @handle_http_errors(create_func_name, service_type="drive")
- async def create_comment(
- service, user_google_email: str, presentation_id: str, comment_content: str
- ) -> str:
- """Create a new comment on a Google Presentation."""
- return await _create_comment_impl(
- service, app_name, presentation_id, comment_content
- )
-
- @require_google_service("drive", "drive_file")
- @handle_http_errors(reply_func_name, service_type="drive")
- async def reply_to_comment(
+ @handle_http_errors(manage_func_name, service_type="drive")
+ async def manage_comment(
service,
user_google_email: str,
presentation_id: str,
- comment_id: str,
- reply_content: str,
+ action: str,
+ comment_content: Optional[str] = None,
+ comment_id: Optional[str] = None,
) -> str:
- """Reply to a specific comment in a Google Presentation."""
- return await _reply_to_comment_impl(
- service, app_name, presentation_id, comment_id, reply_content
+ """Manage comments on a Google Presentation.
+
+ Actions:
+ - create: Create a new comment. Requires comment_content.
+ - reply: Reply to a comment. Requires comment_id and comment_content.
+ - resolve: Resolve a comment. Requires comment_id.
+ """
+ return await _manage_comment_dispatch(
+ service, app_name, presentation_id, action, comment_content, comment_id
)
- @require_google_service("drive", "drive_file")
- @handle_http_errors(resolve_func_name, service_type="drive")
- async def resolve_comment(
- service, user_google_email: str, presentation_id: str, comment_id: str
- ) -> str:
- """Resolve a comment in a Google Presentation."""
- return await _resolve_comment_impl(
- service, app_name, presentation_id, comment_id
- )
-
- # Set the proper function names and register with server
- read_comments.__name__ = read_func_name
- create_comment.__name__ = create_func_name
- reply_to_comment.__name__ = reply_func_name
- resolve_comment.__name__ = resolve_func_name
-
- # Register tools with the server using the proper names
- server.tool()(read_comments)
- server.tool()(create_comment)
- server.tool()(reply_to_comment)
- server.tool()(resolve_comment)
+ list_comments.__name__ = list_func_name
+ manage_comment.__name__ = manage_func_name
+ server.tool()(list_comments)
+ server.tool()(manage_comment)
return {
- "read_comments": read_comments,
- "create_comment": create_comment,
- "reply_to_comment": reply_to_comment,
- "resolve_comment": resolve_comment,
+ "list_comments": list_comments,
+ "manage_comment": manage_comment,
}
diff --git a/core/tool_tiers.yaml b/core/tool_tiers.yaml
index ca929d7..666833b 100644
--- a/core/tool_tiers.yaml
+++ b/core/tool_tiers.yaml
@@ -13,8 +13,7 @@ gmail:
- manage_gmail_label
- draft_gmail_message
- list_gmail_filters
- - create_gmail_filter
- - delete_gmail_filter
+ - manage_gmail_filter
complete:
- get_gmail_threads_content_batch
@@ -29,16 +28,12 @@ drive:
- create_drive_file
- create_drive_folder
- import_to_google_doc
- - share_drive_file
- get_drive_shareable_link
extended:
- list_drive_items
- copy_drive_file
- update_drive_file
- - update_drive_permission
- - remove_drive_permission
- - transfer_drive_ownership
- - batch_share_drive_file
+ - manage_drive_access
- set_drive_file_permissions
complete:
- get_drive_file_permissions
@@ -48,10 +43,8 @@ calendar:
core:
- list_calendars
- get_events
- - create_event
- - modify_event
+ - manage_event
extended:
- - delete_event
- query_freebusy
complete: []
@@ -75,10 +68,8 @@ docs:
- inspect_doc_structure
- create_table_with_data
- debug_table_structure
- - read_document_comments
- - create_document_comment
- - reply_to_document_comment
- - resolve_document_comment
+ - list_document_comments
+ - manage_document_comment
sheets:
core:
@@ -91,10 +82,9 @@ sheets:
- format_sheet_range
complete:
- create_sheet
- - read_spreadsheet_comments
- - create_spreadsheet_comment
- - reply_to_spreadsheet_comment
- - resolve_spreadsheet_comment
+ - list_spreadsheet_comments
+ - manage_spreadsheet_comment
+ - manage_conditional_formatting
chat:
core:
@@ -127,53 +117,37 @@ slides:
- get_page
- get_page_thumbnail
complete:
- - read_presentation_comments
- - create_presentation_comment
- - reply_to_presentation_comment
- - resolve_presentation_comment
+ - list_presentation_comments
+ - manage_presentation_comment
tasks:
core:
- get_task
- list_tasks
- - create_task
- - update_task
- extended:
- - delete_task
+ - manage_task
+ extended: []
complete:
- list_task_lists
- get_task_list
- - create_task_list
- - update_task_list
- - delete_task_list
- - move_task
- - clear_completed_tasks
+ - manage_task_list
contacts:
core:
- search_contacts
- get_contact
- list_contacts
- - create_contact
+ - manage_contact
extended:
- - update_contact
- - delete_contact
- list_contact_groups
- get_contact_group
complete:
- - batch_create_contacts
- - batch_update_contacts
- - batch_delete_contacts
- - create_contact_group
- - update_contact_group
- - delete_contact_group
- - modify_contact_group_members
+ - manage_contacts_batch
+ - manage_contact_group
search:
core:
- search_custom
- extended:
- - search_custom_siterestrict
+ extended: []
complete:
- get_search_engine_info
@@ -187,10 +161,8 @@ appscript:
- run_script_function
- generate_trigger_code
extended:
- - create_deployment
+ - manage_deployment
- list_deployments
- - update_deployment
- - delete_deployment
- delete_script_project
- list_versions
- create_version
diff --git a/core/utils.py b/core/utils.py
index c5c61ee..ee91fb3 100644
--- a/core/utils.py
+++ b/core/utils.py
@@ -2,7 +2,6 @@ import io
import logging
import os
import zipfile
-import xml.etree.ElementTree as ET
import ssl
import asyncio
import functools
@@ -10,6 +9,8 @@ import functools
from pathlib import Path
from typing import List, Optional
+from defusedxml import ElementTree as ET
+
from googleapiclient.errors import HttpError
from .api_enablement import get_api_enablement_message
from auth.google_auth import GoogleAuthenticationError
@@ -226,7 +227,7 @@ def extract_office_xml_text(file_bytes: bytes, mime_type: str) -> Optional[str]:
"""
Very light-weight XML scraper for Word, Excel, PowerPoint files.
Returns plain-text if something readable is found, else None.
- No external deps – just std-lib zipfile + ElementTree.
+ Uses zipfile + defusedxml.ElementTree.
"""
shared_strings: List[str] = []
ns_excel_main = "http://schemas.openxmlformats.org/spreadsheetml/2006/main"
diff --git a/gappsscript/apps_script_tools.py b/gappsscript/apps_script_tools.py
index 20cba10..eaf491d 100644
--- a/gappsscript/apps_script_tools.py
+++ b/gappsscript/apps_script_tools.py
@@ -464,31 +464,57 @@ async def _create_deployment_impl(
@server.tool()
-@handle_http_errors("create_deployment", service_type="script")
+@handle_http_errors("manage_deployment", service_type="script")
@require_google_service("script", "script_deployments")
-async def create_deployment(
+async def manage_deployment(
service: Any,
user_google_email: str,
+ action: str,
script_id: str,
- description: str,
+ deployment_id: Optional[str] = None,
+ description: Optional[str] = None,
version_description: Optional[str] = None,
) -> str:
"""
- Creates a new deployment of the script.
+ Manages Apps Script deployments. Supports creating, updating, and deleting deployments.
Args:
service: Injected Google API service client
user_google_email: User's email address
+ action: Action to perform - "create", "update", or "delete"
script_id: The script project ID
- description: Deployment description
- version_description: Optional version description
+ deployment_id: The deployment ID (required for update and delete)
+ description: Deployment description (required for create and update)
+ version_description: Optional version description (for create only)
Returns:
- str: Formatted string with deployment details
+ str: Formatted string with deployment details or confirmation
"""
- return await _create_deployment_impl(
- service, user_google_email, script_id, description, version_description
- )
+ action = action.lower().strip()
+ if action == "create":
+ if description is None or description.strip() == "":
+ raise ValueError("description is required for create action")
+ return await _create_deployment_impl(
+ service, user_google_email, script_id, description, version_description
+ )
+ elif action == "update":
+ if not deployment_id:
+ raise ValueError("deployment_id is required for update action")
+ if description is None or description.strip() == "":
+ raise ValueError("description is required for update action")
+ return await _update_deployment_impl(
+ service, user_google_email, script_id, deployment_id, description
+ )
+ elif action == "delete":
+ if not deployment_id:
+ raise ValueError("deployment_id is required for delete action")
+ return await _delete_deployment_impl(
+ service, user_google_email, script_id, deployment_id
+ )
+ else:
+ raise ValueError(
+ f"Invalid action '{action}'. Must be 'create', 'update', or 'delete'."
+ )
async def _list_deployments_impl(
@@ -578,34 +604,6 @@ async def _update_deployment_impl(
return "\n".join(output)
-@server.tool()
-@handle_http_errors("update_deployment", service_type="script")
-@require_google_service("script", "script_deployments")
-async def update_deployment(
- service: Any,
- user_google_email: str,
- script_id: str,
- deployment_id: str,
- description: Optional[str] = None,
-) -> str:
- """
- Updates an existing deployment configuration.
-
- Args:
- service: Injected Google API service client
- user_google_email: User's email address
- script_id: The script project ID
- deployment_id: The deployment ID to update
- description: Optional new description
-
- Returns:
- str: Formatted string confirming update
- """
- return await _update_deployment_impl(
- service, user_google_email, script_id, deployment_id, description
- )
-
-
async def _delete_deployment_impl(
service: Any,
user_google_email: str,
@@ -630,32 +628,6 @@ async def _delete_deployment_impl(
return output
-@server.tool()
-@handle_http_errors("delete_deployment", service_type="script")
-@require_google_service("script", "script_deployments")
-async def delete_deployment(
- service: Any,
- user_google_email: str,
- script_id: str,
- deployment_id: str,
-) -> str:
- """
- Deletes a deployment.
-
- Args:
- service: Injected Google API service client
- user_google_email: User's email address
- script_id: The script project ID
- deployment_id: The deployment ID to delete
-
- Returns:
- str: Confirmation message
- """
- return await _delete_deployment_impl(
- service, user_google_email, script_id, deployment_id
- )
-
-
async def _list_script_processes_impl(
service: Any,
user_google_email: str,
diff --git a/gcalendar/calendar_tools.py b/gcalendar/calendar_tools.py
index 4bc51f7..13da74c 100644
--- a/gcalendar/calendar_tools.py
+++ b/gcalendar/calendar_tools.py
@@ -534,10 +534,14 @@ async def get_events(
return text_output
-@server.tool()
-@handle_http_errors("create_event", service_type="calendar")
-@require_google_service("calendar", "calendar_events")
-async def create_event(
+# ---------------------------------------------------------------------------
+# Internal implementation functions for event create/modify/delete.
+# These are called by both the consolidated ``manage_event`` tool and the
+# legacy single-action tools.
+# ---------------------------------------------------------------------------
+
+
+async def _create_event_impl(
service,
user_google_email: str,
summary: str,
@@ -558,32 +562,7 @@ async def create_event(
guests_can_invite_others: Optional[bool] = None,
guests_can_see_other_guests: Optional[bool] = None,
) -> str:
- """
- Creates a new event.
-
- Args:
- user_google_email (str): The user's Google email address. Required.
- summary (str): Event title.
- start_time (str): Start time (RFC3339, e.g., "2023-10-27T10:00:00-07:00" or "2023-10-27" for all-day).
- end_time (str): End time (RFC3339, e.g., "2023-10-27T11:00:00-07:00" or "2023-10-28" for all-day).
- calendar_id (str): Calendar ID (default: 'primary').
- description (Optional[str]): Event description.
- location (Optional[str]): Event location.
- attendees (Optional[List[str]]): Attendee email addresses.
- timezone (Optional[str]): Timezone (e.g., "America/New_York").
- attachments (Optional[List[str]]): List of Google Drive file URLs or IDs to attach to the event.
- add_google_meet (bool): Whether to add a Google Meet video conference to the event. Defaults to False.
- reminders (Optional[Union[str, List[Dict[str, Any]]]]): JSON string or list of reminder objects. Each should have 'method' ("popup" or "email") and 'minutes' (0-40320). Max 5 reminders. Example: '[{"method": "popup", "minutes": 15}]' or [{"method": "popup", "minutes": 15}]
- use_default_reminders (bool): Whether to use calendar's default reminders. If False, uses custom reminders. Defaults to True.
- transparency (Optional[str]): Event transparency for busy/free status. "opaque" shows as Busy (default), "transparent" shows as Available/Free. Defaults to None (uses Google Calendar default).
- visibility (Optional[str]): Event visibility. "default" uses calendar default, "public" is visible to all, "private" is visible only to attendees, "confidential" is same as private (legacy). Defaults to None (uses Google Calendar default).
- guests_can_modify (Optional[bool]): Whether attendees other than the organizer can modify the event. Defaults to None (uses Google Calendar default of False).
- guests_can_invite_others (Optional[bool]): Whether attendees other than the organizer can invite others to the event. Defaults to None (uses Google Calendar default of True).
- guests_can_see_other_guests (Optional[bool]): Whether attendees other than the organizer can see who the event's attendees are. Defaults to None (uses Google Calendar default of True).
-
- Returns:
- str: Confirmation message of the successful event creation with event link.
- """
+ """Internal implementation for creating a calendar event."""
logger.info(
f"[create_event] Invoked. Email: '{user_google_email}', Summary: {summary}"
)
@@ -809,10 +788,7 @@ def _normalize_attendees(
return normalized if normalized else None
-@server.tool()
-@handle_http_errors("modify_event", service_type="calendar")
-@require_google_service("calendar", "calendar_events")
-async def modify_event(
+async def _modify_event_impl(
service,
user_google_email: str,
event_id: str,
@@ -834,33 +810,7 @@ async def modify_event(
guests_can_invite_others: Optional[bool] = None,
guests_can_see_other_guests: Optional[bool] = None,
) -> str:
- """
- Modifies an existing event.
-
- Args:
- user_google_email (str): The user's Google email address. Required.
- event_id (str): The ID of the event to modify.
- calendar_id (str): Calendar ID (default: 'primary').
- summary (Optional[str]): New event title.
- start_time (Optional[str]): New start time (RFC3339, e.g., "2023-10-27T10:00:00-07:00" or "2023-10-27" for all-day).
- end_time (Optional[str]): New end time (RFC3339, e.g., "2023-10-27T11:00:00-07:00" or "2023-10-28" for all-day).
- description (Optional[str]): New event description.
- location (Optional[str]): New event location.
- attendees (Optional[Union[List[str], List[Dict[str, Any]]]]): Attendees as email strings or objects with metadata. Supports: ["email@example.com"] or [{"email": "email@example.com", "responseStatus": "accepted", "organizer": true, "optional": true}]. When using objects, existing metadata (responseStatus, organizer, optional) is preserved. New attendees default to responseStatus="needsAction".
- timezone (Optional[str]): New timezone (e.g., "America/New_York").
- add_google_meet (Optional[bool]): Whether to add or remove Google Meet video conference. If True, adds Google Meet; if False, removes it; if None, leaves unchanged.
- reminders (Optional[Union[str, List[Dict[str, Any]]]]): JSON string or list of reminder objects to replace existing reminders. Each should have 'method' ("popup" or "email") and 'minutes' (0-40320). Max 5 reminders. Example: '[{"method": "popup", "minutes": 15}]' or [{"method": "popup", "minutes": 15}]
- use_default_reminders (Optional[bool]): Whether to use calendar's default reminders. If specified, overrides current reminder settings.
- transparency (Optional[str]): Event transparency for busy/free status. "opaque" shows as Busy, "transparent" shows as Available/Free. If None, preserves existing transparency setting.
- visibility (Optional[str]): Event visibility. "default" uses calendar default, "public" is visible to all, "private" is visible only to attendees, "confidential" is same as private (legacy). If None, preserves existing visibility setting.
- color_id (Optional[str]): Event color ID (1-11). If None, preserves existing color.
- guests_can_modify (Optional[bool]): Whether attendees other than the organizer can modify the event. If None, preserves existing setting.
- guests_can_invite_others (Optional[bool]): Whether attendees other than the organizer can invite others to the event. If None, preserves existing setting.
- guests_can_see_other_guests (Optional[bool]): Whether attendees other than the organizer can see who the event's attendees are. If None, preserves existing setting.
-
- Returns:
- str: Confirmation message of the successful event modification with event link.
- """
+ """Internal implementation for modifying a calendar event."""
logger.info(
f"[modify_event] Invoked. Email: '{user_google_email}', Event ID: {event_id}"
)
@@ -1075,23 +1025,13 @@ async def modify_event(
return confirmation_message
-@server.tool()
-@handle_http_errors("delete_event", service_type="calendar")
-@require_google_service("calendar", "calendar_events")
-async def delete_event(
- service, user_google_email: str, event_id: str, calendar_id: str = "primary"
+async def _delete_event_impl(
+ service,
+ user_google_email: str,
+ event_id: str,
+ calendar_id: str = "primary",
) -> str:
- """
- Deletes an existing event.
-
- Args:
- user_google_email (str): The user's Google email address. Required.
- event_id (str): The ID of the event to delete.
- calendar_id (str): Calendar ID (default: 'primary').
-
- Returns:
- str: Confirmation message of the successful event deletion.
- """
+ """Internal implementation for deleting a calendar event."""
logger.info(
f"[delete_event] Invoked. Email: '{user_google_email}', Event ID: {event_id}"
)
@@ -1133,6 +1073,141 @@ async def delete_event(
return confirmation_message
+# ---------------------------------------------------------------------------
+# Consolidated event management tool
+# ---------------------------------------------------------------------------
+
+
+@server.tool()
+@handle_http_errors("manage_event", service_type="calendar")
+@require_google_service("calendar", "calendar_events")
+async def manage_event(
+ service,
+ user_google_email: str,
+ action: str,
+ summary: Optional[str] = None,
+ start_time: Optional[str] = None,
+ end_time: Optional[str] = None,
+ event_id: Optional[str] = None,
+ calendar_id: str = "primary",
+ description: Optional[str] = None,
+ location: Optional[str] = None,
+ attendees: Optional[Union[List[str], List[Dict[str, Any]]]] = None,
+ timezone: Optional[str] = None,
+ attachments: Optional[List[str]] = None,
+ add_google_meet: Optional[bool] = None,
+ reminders: Optional[Union[str, List[Dict[str, Any]]]] = None,
+ use_default_reminders: Optional[bool] = None,
+ transparency: Optional[str] = None,
+ visibility: Optional[str] = None,
+ color_id: Optional[str] = None,
+ guests_can_modify: Optional[bool] = None,
+ guests_can_invite_others: Optional[bool] = None,
+ guests_can_see_other_guests: Optional[bool] = None,
+) -> str:
+ """
+ Manages calendar events. Supports creating, updating, and deleting events.
+
+ Args:
+ user_google_email (str): The user's Google email address. Required.
+ action (str): Action to perform - "create", "update", or "delete".
+ summary (Optional[str]): Event title (required for create).
+ start_time (Optional[str]): Start time in RFC3339 format (required for create).
+ end_time (Optional[str]): End time in RFC3339 format (required for create).
+ event_id (Optional[str]): Event ID (required for update and delete).
+ calendar_id (str): Calendar ID (default: 'primary').
+ description (Optional[str]): Event description.
+ location (Optional[str]): Event location.
+ attendees (Optional[Union[List[str], List[Dict[str, Any]]]]): Attendee email addresses or objects.
+ timezone (Optional[str]): Timezone (e.g., "America/New_York").
+ attachments (Optional[List[str]]): List of Google Drive file URLs or IDs to attach.
+ add_google_meet (Optional[bool]): Whether to add/remove Google Meet.
+ reminders (Optional[Union[str, List[Dict[str, Any]]]]): Custom reminder objects.
+ use_default_reminders (Optional[bool]): Whether to use default reminders.
+ transparency (Optional[str]): "opaque" (busy) or "transparent" (free).
+ visibility (Optional[str]): "default", "public", "private", or "confidential".
+ color_id (Optional[str]): Event color ID (1-11, update only).
+ guests_can_modify (Optional[bool]): Whether attendees can modify.
+ guests_can_invite_others (Optional[bool]): Whether attendees can invite others.
+ guests_can_see_other_guests (Optional[bool]): Whether attendees can see other guests.
+
+ Returns:
+ str: Confirmation message with event details.
+ """
+ action_lower = action.lower().strip()
+ if action_lower == "create":
+ if not summary or not start_time or not end_time:
+ raise ValueError(
+ "summary, start_time, and end_time are required for create action"
+ )
+ return await _create_event_impl(
+ service=service,
+ user_google_email=user_google_email,
+ summary=summary,
+ start_time=start_time,
+ end_time=end_time,
+ calendar_id=calendar_id,
+ description=description,
+ location=location,
+ attendees=attendees,
+ timezone=timezone,
+ attachments=attachments,
+ add_google_meet=add_google_meet or False,
+ reminders=reminders,
+ use_default_reminders=use_default_reminders
+ if use_default_reminders is not None
+ else True,
+ transparency=transparency,
+ visibility=visibility,
+ guests_can_modify=guests_can_modify,
+ guests_can_invite_others=guests_can_invite_others,
+ guests_can_see_other_guests=guests_can_see_other_guests,
+ )
+ elif action_lower == "update":
+ if not event_id:
+ raise ValueError("event_id is required for update action")
+ return await _modify_event_impl(
+ service=service,
+ user_google_email=user_google_email,
+ event_id=event_id,
+ calendar_id=calendar_id,
+ summary=summary,
+ start_time=start_time,
+ end_time=end_time,
+ description=description,
+ location=location,
+ attendees=attendees,
+ timezone=timezone,
+ add_google_meet=add_google_meet,
+ reminders=reminders,
+ use_default_reminders=use_default_reminders,
+ transparency=transparency,
+ visibility=visibility,
+ color_id=color_id,
+ guests_can_modify=guests_can_modify,
+ guests_can_invite_others=guests_can_invite_others,
+ guests_can_see_other_guests=guests_can_see_other_guests,
+ )
+ elif action_lower == "delete":
+ if not event_id:
+ raise ValueError("event_id is required for delete action")
+ return await _delete_event_impl(
+ service=service,
+ user_google_email=user_google_email,
+ event_id=event_id,
+ calendar_id=calendar_id,
+ )
+ else:
+ raise ValueError(
+ f"Invalid action '{action_lower}'. Must be 'create', 'update', or 'delete'."
+ )
+
+
+# ---------------------------------------------------------------------------
+# Legacy single-action tools (deprecated -- prefer ``manage_event``)
+# ---------------------------------------------------------------------------
+
+
@server.tool()
@handle_http_errors("query_freebusy", is_read_only=True, service_type="calendar")
@require_google_service("calendar", "calendar_read")
diff --git a/gcontacts/contacts_tools.py b/gcontacts/contacts_tools.py
index 38e08f8..ab04053 100644
--- a/gcontacts/contacts_tools.py
+++ b/gcontacts/contacts_tools.py
@@ -13,7 +13,7 @@ from mcp import Resource
from auth.service_decorator import require_google_service
from core.server import server
-from core.utils import handle_http_errors
+from core.utils import UserInputError, handle_http_errors
logger = logging.getLogger(__name__)
@@ -249,48 +249,44 @@ async def list_contacts(
"""
logger.info(f"[list_contacts] Invoked. Email: '{user_google_email}'")
- try:
- params: Dict[str, Any] = {
- "resourceName": "people/me",
- "personFields": DEFAULT_PERSON_FIELDS,
- "pageSize": min(page_size, 1000),
- }
+ if page_size < 1:
+ raise UserInputError("page_size must be >= 1")
+ page_size = min(page_size, 1000)
- if page_token:
- params["pageToken"] = page_token
- if sort_order:
- params["sortOrder"] = sort_order
+ params: Dict[str, Any] = {
+ "resourceName": "people/me",
+ "personFields": DEFAULT_PERSON_FIELDS,
+ "pageSize": page_size,
+ }
- result = await asyncio.to_thread(
- service.people().connections().list(**params).execute
- )
+ if page_token:
+ params["pageToken"] = page_token
+ if sort_order:
+ params["sortOrder"] = sort_order
- connections = result.get("connections", [])
- next_page_token = result.get("nextPageToken")
- total_people = result.get("totalPeople", len(connections))
+ result = await asyncio.to_thread(
+ service.people().connections().list(**params).execute
+ )
- if not connections:
- return f"No contacts found for {user_google_email}."
+ connections = result.get("connections", [])
+ next_page_token = result.get("nextPageToken")
+ total_people = result.get("totalPeople", len(connections))
- response = f"Contacts for {user_google_email} ({len(connections)} of {total_people}):\n\n"
+ if not connections:
+ return f"No contacts found for {user_google_email}."
- for person in connections:
- response += _format_contact(person) + "\n\n"
+ response = (
+ f"Contacts for {user_google_email} ({len(connections)} of {total_people}):\n\n"
+ )
- if next_page_token:
- response += f"Next page token: {next_page_token}"
+ for person in connections:
+ response += _format_contact(person) + "\n\n"
- logger.info(f"Found {len(connections)} contacts for {user_google_email}")
- return response
+ if next_page_token:
+ response += f"Next page token: {next_page_token}"
- except HttpError as error:
- message = f"API error: {error}. You might need to re-authenticate. LLM: Try 'start_google_auth' with the user's email ({user_google_email}) and service_name='Google Contacts'."
- logger.error(message, exc_info=True)
- raise Exception(message)
- except Exception as e:
- message = f"Unexpected error: {e}."
- logger.exception(message)
- raise Exception(message)
+ logger.info(f"Found {len(connections)} contacts for {user_google_email}")
+ return response
@server.tool()
@@ -321,31 +317,17 @@ async def get_contact(
f"[get_contact] Invoked. Email: '{user_google_email}', Contact: {resource_name}"
)
- try:
- person = await asyncio.to_thread(
- service.people()
- .get(resourceName=resource_name, personFields=DETAILED_PERSON_FIELDS)
- .execute
- )
+ person = await asyncio.to_thread(
+ service.people()
+ .get(resourceName=resource_name, personFields=DETAILED_PERSON_FIELDS)
+ .execute
+ )
- response = f"Contact Details for {user_google_email}:\n\n"
- response += _format_contact(person, detailed=True)
+ response = f"Contact Details for {user_google_email}:\n\n"
+ response += _format_contact(person, detailed=True)
- logger.info(f"Retrieved contact {resource_name} for {user_google_email}")
- return response
-
- except HttpError as error:
- if error.resp.status == 404:
- message = f"Contact not found: {contact_id}"
- logger.warning(message)
- raise Exception(message)
- message = f"API error: {error}. You might need to re-authenticate. LLM: Try 'start_google_auth' with the user's email ({user_google_email}) and service_name='Google Contacts'."
- logger.error(message, exc_info=True)
- raise Exception(message)
- except Exception as e:
- message = f"Unexpected error: {e}."
- logger.exception(message)
- raise Exception(message)
+ logger.info(f"Retrieved contact {resource_name} for {user_google_email}")
+ return response
@server.tool()
@@ -372,52 +354,48 @@ async def search_contacts(
f"[search_contacts] Invoked. Email: '{user_google_email}', Query: '{query}'"
)
- try:
- # Warm up the search cache if needed
- await _warmup_search_cache(service, user_google_email)
+ if page_size < 1:
+ raise UserInputError("page_size must be >= 1")
+ page_size = min(page_size, 30)
- result = await asyncio.to_thread(
- service.people()
- .searchContacts(
- query=query,
- readMask=DEFAULT_PERSON_FIELDS,
- pageSize=min(page_size, 30),
- )
- .execute
+ # Warm up the search cache if needed
+ await _warmup_search_cache(service, user_google_email)
+
+ result = await asyncio.to_thread(
+ service.people()
+ .searchContacts(
+ query=query,
+ readMask=DEFAULT_PERSON_FIELDS,
+ pageSize=page_size,
)
+ .execute
+ )
- results = result.get("results", [])
+ results = result.get("results", [])
- if not results:
- return f"No contacts found matching '{query}' for {user_google_email}."
+ if not results:
+ return f"No contacts found matching '{query}' for {user_google_email}."
- response = f"Search Results for '{query}' ({len(results)} found):\n\n"
+ response = f"Search Results for '{query}' ({len(results)} found):\n\n"
- for item in results:
- person = item.get("person", {})
- response += _format_contact(person) + "\n\n"
+ for item in results:
+ person = item.get("person", {})
+ response += _format_contact(person) + "\n\n"
- logger.info(
- f"Found {len(results)} contacts matching '{query}' for {user_google_email}"
- )
- return response
-
- except HttpError as error:
- message = f"API error: {error}. You might need to re-authenticate. LLM: Try 'start_google_auth' with the user's email ({user_google_email}) and service_name='Google Contacts'."
- logger.error(message, exc_info=True)
- raise Exception(message)
- except Exception as e:
- message = f"Unexpected error: {e}."
- logger.exception(message)
- raise Exception(message)
+ logger.info(
+ f"Found {len(results)} contacts matching '{query}' for {user_google_email}"
+ )
+ return response
@server.tool()
@require_google_service("people", "contacts")
-@handle_http_errors("create_contact", service_type="people")
-async def create_contact(
+@handle_http_errors("manage_contact", service_type="people")
+async def manage_contact(
service: Resource,
user_google_email: str,
+ action: str,
+ contact_id: Optional[str] = None,
given_name: Optional[str] = None,
family_name: Optional[str] = None,
email: Optional[str] = None,
@@ -427,26 +405,35 @@ async def create_contact(
notes: Optional[str] = None,
) -> str:
"""
- Create a new contact.
+ Create, update, or delete a contact. Consolidated tool replacing create_contact,
+ update_contact, and delete_contact.
Args:
user_google_email (str): The user's Google email address. Required.
- given_name (Optional[str]): First name.
- family_name (Optional[str]): Last name.
- email (Optional[str]): Email address.
- phone (Optional[str]): Phone number.
- organization (Optional[str]): Company/organization name.
- job_title (Optional[str]): Job title.
- notes (Optional[str]): Additional notes.
+ action (str): The action to perform: "create", "update", or "delete".
+ contact_id (Optional[str]): The contact ID. Required for "update" and "delete" actions.
+ given_name (Optional[str]): First name (for create/update).
+ family_name (Optional[str]): Last name (for create/update).
+ email (Optional[str]): Email address (for create/update).
+ phone (Optional[str]): Phone number (for create/update).
+ organization (Optional[str]): Company/organization name (for create/update).
+ job_title (Optional[str]): Job title (for create/update).
+ notes (Optional[str]): Additional notes (for create/update).
Returns:
- str: Confirmation with the new contact's details.
+ str: Result of the action performed.
"""
+ action = action.lower().strip()
+ if action not in ("create", "update", "delete"):
+ raise UserInputError(
+ f"Invalid action '{action}'. Must be 'create', 'update', or 'delete'."
+ )
+
logger.info(
- f"[create_contact] Invoked. Email: '{user_google_email}', Name: '{given_name} {family_name}'"
+ f"[manage_contact] Invoked. Action: '{action}', Email: '{user_google_email}'"
)
- try:
+ if action == "create":
body = _build_person_body(
given_name=given_name,
family_name=family_name,
@@ -458,7 +445,7 @@ async def create_contact(
)
if not body:
- raise Exception(
+ raise UserInputError(
"At least one field (name, email, phone, etc.) must be provided."
)
@@ -471,69 +458,22 @@ async def create_contact(
response = f"Contact Created for {user_google_email}:\n\n"
response += _format_contact(result, detailed=True)
- contact_id = result.get("resourceName", "").replace("people/", "")
- logger.info(f"Created contact {contact_id} for {user_google_email}")
+ created_id = result.get("resourceName", "").replace("people/", "")
+ logger.info(f"Created contact {created_id} for {user_google_email}")
return response
- except HttpError as error:
- message = f"API error: {error}. You might need to re-authenticate. LLM: Try 'start_google_auth' with the user's email ({user_google_email}) and service_name='Google Contacts'."
- logger.error(message, exc_info=True)
- raise Exception(message)
- except Exception as e:
- message = f"Unexpected error: {e}."
- logger.exception(message)
- raise Exception(message)
+ # update and delete both require contact_id
+ if not contact_id:
+ raise UserInputError(f"contact_id is required for '{action}' action.")
-
-# =============================================================================
-# Extended Tier Tools
-# =============================================================================
-
-
-@server.tool()
-@require_google_service("people", "contacts")
-@handle_http_errors("update_contact", service_type="people")
-async def update_contact(
- service: Resource,
- user_google_email: str,
- contact_id: str,
- given_name: Optional[str] = None,
- family_name: Optional[str] = None,
- email: Optional[str] = None,
- phone: Optional[str] = None,
- organization: Optional[str] = None,
- job_title: Optional[str] = None,
- notes: Optional[str] = None,
-) -> str:
- """
- Update an existing contact. Note: This replaces fields, not merges them.
-
- Args:
- user_google_email (str): The user's Google email address. Required.
- contact_id (str): The contact ID to update.
- given_name (Optional[str]): New first name.
- family_name (Optional[str]): New last name.
- email (Optional[str]): New email address.
- phone (Optional[str]): New phone number.
- organization (Optional[str]): New company/organization name.
- job_title (Optional[str]): New job title.
- notes (Optional[str]): New notes.
-
- Returns:
- str: Confirmation with updated contact details.
- """
# Normalize resource name
if not contact_id.startswith("people/"):
resource_name = f"people/{contact_id}"
else:
resource_name = contact_id
- logger.info(
- f"[update_contact] Invoked. Email: '{user_google_email}', Contact: {resource_name}"
- )
-
- try:
- # First fetch the contact to get the etag
+ if action == "update":
+ # Fetch the contact to get the etag
current = await asyncio.to_thread(
service.people()
.get(resourceName=resource_name, personFields=DETAILED_PERSON_FIELDS)
@@ -544,7 +484,6 @@ async def update_contact(
if not etag:
raise Exception("Unable to get contact etag for update.")
- # Build update body
body = _build_person_body(
given_name=given_name,
family_name=family_name,
@@ -556,13 +495,12 @@ async def update_contact(
)
if not body:
- raise Exception(
+ raise UserInputError(
"At least one field (name, email, phone, etc.) must be provided."
)
body["etag"] = etag
- # Determine which fields to update
update_person_fields = []
if "names" in body:
update_person_fields.append("names")
@@ -594,70 +532,19 @@ async def update_contact(
logger.info(f"Updated contact {resource_name} for {user_google_email}")
return response
- except HttpError as error:
- if error.resp.status == 404:
- message = f"Contact not found: {contact_id}"
- logger.warning(message)
- raise Exception(message)
- message = f"API error: {error}. You might need to re-authenticate. LLM: Try 'start_google_auth' with the user's email ({user_google_email}) and service_name='Google Contacts'."
- logger.error(message, exc_info=True)
- raise Exception(message)
- except Exception as e:
- message = f"Unexpected error: {e}."
- logger.exception(message)
- raise Exception(message)
-
-
-@server.tool()
-@require_google_service("people", "contacts")
-@handle_http_errors("delete_contact", service_type="people")
-async def delete_contact(
- service: Resource,
- user_google_email: str,
- contact_id: str,
-) -> str:
- """
- Delete a contact.
-
- Args:
- user_google_email (str): The user's Google email address. Required.
- contact_id (str): The contact ID to delete.
-
- Returns:
- str: Confirmation message.
- """
- # Normalize resource name
- if not contact_id.startswith("people/"):
- resource_name = f"people/{contact_id}"
- else:
- resource_name = contact_id
-
- logger.info(
- f"[delete_contact] Invoked. Email: '{user_google_email}', Contact: {resource_name}"
+ # action == "delete"
+ await asyncio.to_thread(
+ service.people().deleteContact(resourceName=resource_name).execute
)
- try:
- await asyncio.to_thread(
- service.people().deleteContact(resourceName=resource_name).execute
- )
+ response = f"Contact {contact_id} has been deleted for {user_google_email}."
+ logger.info(f"Deleted contact {resource_name} for {user_google_email}")
+ return response
- response = f"Contact {contact_id} has been deleted for {user_google_email}."
- logger.info(f"Deleted contact {resource_name} for {user_google_email}")
- return response
-
- except HttpError as error:
- if error.resp.status == 404:
- message = f"Contact not found: {contact_id}"
- logger.warning(message)
- raise Exception(message)
- message = f"API error: {error}. You might need to re-authenticate. LLM: Try 'start_google_auth' with the user's email ({user_google_email}) and service_name='Google Contacts'."
- logger.error(message, exc_info=True)
- raise Exception(message)
- except Exception as e:
- message = f"Unexpected error: {e}."
- logger.exception(message)
- raise Exception(message)
+# =============================================================================
+# Extended Tier Tools
+# =============================================================================
@server.tool()
@@ -682,51 +569,45 @@ async def list_contact_groups(
"""
logger.info(f"[list_contact_groups] Invoked. Email: '{user_google_email}'")
- try:
- params: Dict[str, Any] = {
- "pageSize": min(page_size, 1000),
- "groupFields": CONTACT_GROUP_FIELDS,
- }
+ if page_size < 1:
+ raise UserInputError("page_size must be >= 1")
+ page_size = min(page_size, 1000)
- if page_token:
- params["pageToken"] = page_token
+ params: Dict[str, Any] = {
+ "pageSize": page_size,
+ "groupFields": CONTACT_GROUP_FIELDS,
+ }
- result = await asyncio.to_thread(service.contactGroups().list(**params).execute)
+ if page_token:
+ params["pageToken"] = page_token
- groups = result.get("contactGroups", [])
- next_page_token = result.get("nextPageToken")
+ result = await asyncio.to_thread(service.contactGroups().list(**params).execute)
- if not groups:
- return f"No contact groups found for {user_google_email}."
+ groups = result.get("contactGroups", [])
+ next_page_token = result.get("nextPageToken")
- response = f"Contact Groups for {user_google_email}:\n\n"
+ if not groups:
+ return f"No contact groups found for {user_google_email}."
- for group in groups:
- resource_name = group.get("resourceName", "")
- group_id = resource_name.replace("contactGroups/", "")
- name = group.get("name", "Unnamed")
- group_type = group.get("groupType", "USER_CONTACT_GROUP")
- member_count = group.get("memberCount", 0)
+ response = f"Contact Groups for {user_google_email}:\n\n"
- response += f"- {name}\n"
- response += f" ID: {group_id}\n"
- response += f" Type: {group_type}\n"
- response += f" Members: {member_count}\n\n"
+ for group in groups:
+ resource_name = group.get("resourceName", "")
+ group_id = resource_name.replace("contactGroups/", "")
+ name = group.get("name", "Unnamed")
+ group_type = group.get("groupType", "USER_CONTACT_GROUP")
+ member_count = group.get("memberCount", 0)
- if next_page_token:
- response += f"Next page token: {next_page_token}"
+ response += f"- {name}\n"
+ response += f" ID: {group_id}\n"
+ response += f" Type: {group_type}\n"
+ response += f" Members: {member_count}\n\n"
- logger.info(f"Found {len(groups)} contact groups for {user_google_email}")
- return response
+ if next_page_token:
+ response += f"Next page token: {next_page_token}"
- except HttpError as error:
- message = f"API error: {error}. You might need to re-authenticate. LLM: Try 'start_google_auth' with the user's email ({user_google_email}) and service_name='Google Contacts'."
- logger.error(message, exc_info=True)
- raise Exception(message)
- except Exception as e:
- message = f"Unexpected error: {e}."
- logger.exception(message)
- raise Exception(message)
+ logger.info(f"Found {len(groups)} contact groups for {user_google_email}")
+ return response
@server.tool()
@@ -759,49 +640,39 @@ async def get_contact_group(
f"[get_contact_group] Invoked. Email: '{user_google_email}', Group: {resource_name}"
)
- try:
- result = await asyncio.to_thread(
- service.contactGroups()
- .get(
- resourceName=resource_name,
- maxMembers=min(max_members, 1000),
- groupFields=CONTACT_GROUP_FIELDS,
- )
- .execute
+ if max_members < 1:
+ raise UserInputError("max_members must be >= 1")
+ max_members = min(max_members, 1000)
+
+ result = await asyncio.to_thread(
+ service.contactGroups()
+ .get(
+ resourceName=resource_name,
+ maxMembers=max_members,
+ groupFields=CONTACT_GROUP_FIELDS,
)
+ .execute
+ )
- name = result.get("name", "Unnamed")
- group_type = result.get("groupType", "USER_CONTACT_GROUP")
- member_count = result.get("memberCount", 0)
- member_resource_names = result.get("memberResourceNames", [])
+ name = result.get("name", "Unnamed")
+ group_type = result.get("groupType", "USER_CONTACT_GROUP")
+ member_count = result.get("memberCount", 0)
+ member_resource_names = result.get("memberResourceNames", [])
- response = f"Contact Group Details for {user_google_email}:\n\n"
- response += f"Name: {name}\n"
- response += f"ID: {group_id}\n"
- response += f"Type: {group_type}\n"
- response += f"Total Members: {member_count}\n"
+ response = f"Contact Group Details for {user_google_email}:\n\n"
+ response += f"Name: {name}\n"
+ response += f"ID: {group_id}\n"
+ response += f"Type: {group_type}\n"
+ response += f"Total Members: {member_count}\n"
- if member_resource_names:
- response += f"\nMembers ({len(member_resource_names)} shown):\n"
- for member in member_resource_names:
- contact_id = member.replace("people/", "")
- response += f" - {contact_id}\n"
+ if member_resource_names:
+ response += f"\nMembers ({len(member_resource_names)} shown):\n"
+ for member in member_resource_names:
+ contact_id = member.replace("people/", "")
+ response += f" - {contact_id}\n"
- logger.info(f"Retrieved contact group {resource_name} for {user_google_email}")
- return response
-
- except HttpError as error:
- if error.resp.status == 404:
- message = f"Contact group not found: {group_id}"
- logger.warning(message)
- raise Exception(message)
- message = f"API error: {error}. You might need to re-authenticate. LLM: Try 'start_google_auth' with the user's email ({user_google_email}) and service_name='Google Contacts'."
- logger.error(message, exc_info=True)
- raise Exception(message)
- except Exception as e:
- message = f"Unexpected error: {e}."
- logger.exception(message)
- raise Exception(message)
+ logger.info(f"Retrieved contact group {resource_name} for {user_google_email}")
+ return response
# =============================================================================
@@ -811,40 +682,49 @@ async def get_contact_group(
@server.tool()
@require_google_service("people", "contacts")
-@handle_http_errors("batch_create_contacts", service_type="people")
-async def batch_create_contacts(
+@handle_http_errors("manage_contacts_batch", service_type="people")
+async def manage_contacts_batch(
service: Resource,
user_google_email: str,
- contacts: List[Dict[str, str]],
+ action: str,
+ contacts: Optional[List[Dict[str, str]]] = None,
+ updates: Optional[List[Dict[str, str]]] = None,
+ contact_ids: Optional[List[str]] = None,
) -> str:
"""
- Create multiple contacts in a batch operation.
+ Batch create, update, or delete contacts. Consolidated tool replacing
+ batch_create_contacts, batch_update_contacts, and batch_delete_contacts.
Args:
user_google_email (str): The user's Google email address. Required.
- contacts (List[Dict[str, str]]): List of contact dictionaries with fields:
- - given_name: First name
- - family_name: Last name
- - email: Email address
- - phone: Phone number
- - organization: Company name
- - job_title: Job title
+ action (str): The action to perform: "create", "update", or "delete".
+ contacts (Optional[List[Dict[str, str]]]): List of contact dicts for "create" action.
+ Each dict may contain: given_name, family_name, email, phone, organization, job_title.
+ updates (Optional[List[Dict[str, str]]]): List of update dicts for "update" action.
+ Each dict must contain contact_id and may contain: given_name, family_name,
+ email, phone, organization, job_title.
+ contact_ids (Optional[List[str]]): List of contact IDs for "delete" action.
Returns:
- str: Confirmation with created contacts.
+ str: Result of the batch action performed.
"""
+ action = action.lower().strip()
+ if action not in ("create", "update", "delete"):
+ raise UserInputError(
+ f"Invalid action '{action}'. Must be 'create', 'update', or 'delete'."
+ )
+
logger.info(
- f"[batch_create_contacts] Invoked. Email: '{user_google_email}', Count: {len(contacts)}"
+ f"[manage_contacts_batch] Invoked. Action: '{action}', Email: '{user_google_email}'"
)
- try:
+ if action == "create":
if not contacts:
- raise Exception("At least one contact must be provided.")
+ raise UserInputError("contacts parameter is required for 'create' action.")
if len(contacts) > 200:
- raise Exception("Maximum 200 contacts can be created in a batch.")
+ raise UserInputError("Maximum 200 contacts can be created in a batch.")
- # Build batch request body
contact_bodies = []
for contact in contacts:
body = _build_person_body(
@@ -859,7 +739,7 @@ async def batch_create_contacts(
contact_bodies.append({"contactPerson": body})
if not contact_bodies:
- raise Exception("No valid contact data provided.")
+ raise UserInputError("No valid contact data provided.")
batch_body = {
"contacts": contact_bodies,
@@ -884,63 +764,23 @@ async def batch_create_contacts(
)
return response
- except HttpError as error:
- message = f"API error: {error}. You might need to re-authenticate. LLM: Try 'start_google_auth' with the user's email ({user_google_email}) and service_name='Google Contacts'."
- logger.error(message, exc_info=True)
- raise Exception(message)
- except Exception as e:
- message = f"Unexpected error: {e}."
- logger.exception(message)
- raise Exception(message)
-
-
-@server.tool()
-@require_google_service("people", "contacts")
-@handle_http_errors("batch_update_contacts", service_type="people")
-async def batch_update_contacts(
- service: Resource,
- user_google_email: str,
- updates: List[Dict[str, str]],
-) -> str:
- """
- Update multiple contacts in a batch operation.
-
- Args:
- user_google_email (str): The user's Google email address. Required.
- updates (List[Dict[str, str]]): List of update dictionaries with fields:
- - contact_id: The contact ID to update (required)
- - given_name: New first name
- - family_name: New last name
- - email: New email address
- - phone: New phone number
- - organization: New company name
- - job_title: New job title
-
- Returns:
- str: Confirmation with updated contacts.
- """
- logger.info(
- f"[batch_update_contacts] Invoked. Email: '{user_google_email}', Count: {len(updates)}"
- )
-
- try:
+ if action == "update":
if not updates:
- raise Exception("At least one update must be provided.")
+ raise UserInputError("updates parameter is required for 'update' action.")
if len(updates) > 200:
- raise Exception("Maximum 200 contacts can be updated in a batch.")
+ raise UserInputError("Maximum 200 contacts can be updated in a batch.")
- # First, fetch all contacts to get their etags
+ # Fetch all contacts to get their etags
resource_names = []
for update in updates:
- contact_id = update.get("contact_id")
- if not contact_id:
- raise Exception("Each update must include a contact_id.")
- if not contact_id.startswith("people/"):
- contact_id = f"people/{contact_id}"
- resource_names.append(contact_id)
+ cid = update.get("contact_id")
+ if not cid:
+ raise UserInputError("Each update must include a contact_id.")
+ if not cid.startswith("people/"):
+ cid = f"people/{cid}"
+ resource_names.append(cid)
- # Batch get contacts for etags
batch_get_result = await asyncio.to_thread(
service.people()
.getBatchGet(
@@ -951,25 +791,24 @@ async def batch_update_contacts(
)
etags = {}
- for response in batch_get_result.get("responses", []):
- person = response.get("person", {})
- resource_name = person.get("resourceName")
+ for resp in batch_get_result.get("responses", []):
+ person = resp.get("person", {})
+ rname = person.get("resourceName")
etag = person.get("etag")
- if resource_name and etag:
- etags[resource_name] = etag
+ if rname and etag:
+ etags[rname] = etag
- # Build batch update body
update_bodies = []
update_fields_set: set = set()
for update in updates:
- contact_id = update.get("contact_id", "")
- if not contact_id.startswith("people/"):
- contact_id = f"people/{contact_id}"
+ cid = update.get("contact_id", "")
+ if not cid.startswith("people/"):
+ cid = f"people/{cid}"
- etag = etags.get(contact_id)
+ etag = etags.get(cid)
if not etag:
- logger.warning(f"No etag found for {contact_id}, skipping")
+ logger.warning(f"No etag found for {cid}, skipping")
continue
body = _build_person_body(
@@ -982,11 +821,10 @@ async def batch_update_contacts(
)
if body:
- body["resourceName"] = contact_id
+ body["resourceName"] = cid
body["etag"] = etag
update_bodies.append({"person": body})
- # Track which fields are being updated
if "names" in body:
update_fields_set.add("names")
if "emailAddresses" in body:
@@ -997,7 +835,7 @@ async def batch_update_contacts(
update_fields_set.add("organizations")
if not update_bodies:
- raise Exception("No valid update data provided.")
+ raise UserInputError("No valid update data provided.")
batch_body = {
"contacts": update_bodies,
@@ -1014,7 +852,7 @@ async def batch_update_contacts(
response = f"Batch Update Results for {user_google_email}:\n\n"
response += f"Updated {len(update_results)} contacts:\n\n"
- for resource_name, update_result in update_results.items():
+ for rname, update_result in update_results.items():
person = update_result.get("person", {})
response += _format_contact(person) + "\n\n"
@@ -1023,99 +861,77 @@ async def batch_update_contacts(
)
return response
- except HttpError as error:
- message = f"API error: {error}. You might need to re-authenticate. LLM: Try 'start_google_auth' with the user's email ({user_google_email}) and service_name='Google Contacts'."
- logger.error(message, exc_info=True)
- raise Exception(message)
- except Exception as e:
- message = f"Unexpected error: {e}."
- logger.exception(message)
- raise Exception(message)
+ # action == "delete"
+ if not contact_ids:
+ raise UserInputError("contact_ids parameter is required for 'delete' action.")
+
+ if len(contact_ids) > 500:
+ raise UserInputError("Maximum 500 contacts can be deleted in a batch.")
+
+ resource_names = []
+ for cid in contact_ids:
+ if not cid.startswith("people/"):
+ resource_names.append(f"people/{cid}")
+ else:
+ resource_names.append(cid)
+
+ batch_body = {"resourceNames": resource_names}
+
+ await asyncio.to_thread(
+ service.people().batchDeleteContacts(body=batch_body).execute
+ )
+
+ response = f"Batch deleted {len(contact_ids)} contacts for {user_google_email}."
+ logger.info(f"Batch deleted {len(contact_ids)} contacts for {user_google_email}")
+ return response
@server.tool()
@require_google_service("people", "contacts")
-@handle_http_errors("batch_delete_contacts", service_type="people")
-async def batch_delete_contacts(
+@handle_http_errors("manage_contact_group", service_type="people")
+async def manage_contact_group(
service: Resource,
user_google_email: str,
- contact_ids: List[str],
+ action: str,
+ group_id: Optional[str] = None,
+ name: Optional[str] = None,
+ delete_contacts: bool = False,
+ add_contact_ids: Optional[List[str]] = None,
+ remove_contact_ids: Optional[List[str]] = None,
) -> str:
"""
- Delete multiple contacts in a batch operation.
+ Create, update, delete a contact group, or modify its members. Consolidated tool
+ replacing create_contact_group, update_contact_group, delete_contact_group, and
+ modify_contact_group_members.
Args:
user_google_email (str): The user's Google email address. Required.
- contact_ids (List[str]): List of contact IDs to delete.
+ action (str): The action to perform: "create", "update", "delete", or "modify_members".
+ group_id (Optional[str]): The contact group ID. Required for "update", "delete",
+ and "modify_members" actions.
+ name (Optional[str]): The group name. Required for "create" and "update" actions.
+ delete_contacts (bool): If True and action is "delete", also delete contacts in
+ the group (default: False).
+ add_contact_ids (Optional[List[str]]): Contact IDs to add (for "modify_members").
+ remove_contact_ids (Optional[List[str]]): Contact IDs to remove (for "modify_members").
Returns:
- str: Confirmation message.
+ str: Result of the action performed.
"""
- logger.info(
- f"[batch_delete_contacts] Invoked. Email: '{user_google_email}', Count: {len(contact_ids)}"
- )
-
- try:
- if not contact_ids:
- raise Exception("At least one contact ID must be provided.")
-
- if len(contact_ids) > 500:
- raise Exception("Maximum 500 contacts can be deleted in a batch.")
-
- # Normalize resource names
- resource_names = []
- for contact_id in contact_ids:
- if not contact_id.startswith("people/"):
- resource_names.append(f"people/{contact_id}")
- else:
- resource_names.append(contact_id)
-
- batch_body = {"resourceNames": resource_names}
-
- await asyncio.to_thread(
- service.people().batchDeleteContacts(body=batch_body).execute
+ action = action.lower().strip()
+ if action not in ("create", "update", "delete", "modify_members"):
+ raise UserInputError(
+ f"Invalid action '{action}'. Must be 'create', 'update', 'delete', or 'modify_members'."
)
- response = f"Batch deleted {len(contact_ids)} contacts for {user_google_email}."
-
- logger.info(
- f"Batch deleted {len(contact_ids)} contacts for {user_google_email}"
- )
- return response
-
- except HttpError as error:
- message = f"API error: {error}. You might need to re-authenticate. LLM: Try 'start_google_auth' with the user's email ({user_google_email}) and service_name='Google Contacts'."
- logger.error(message, exc_info=True)
- raise Exception(message)
- except Exception as e:
- message = f"Unexpected error: {e}."
- logger.exception(message)
- raise Exception(message)
-
-
-@server.tool()
-@require_google_service("people", "contacts")
-@handle_http_errors("create_contact_group", service_type="people")
-async def create_contact_group(
- service: Resource,
- user_google_email: str,
- name: str,
-) -> str:
- """
- Create a new contact group (label).
-
- Args:
- user_google_email (str): The user's Google email address. Required.
- name (str): The name of the new contact group.
-
- Returns:
- str: Confirmation with the new group details.
- """
logger.info(
- f"[create_contact_group] Invoked. Email: '{user_google_email}', Name: '{name}'"
+ f"[manage_contact_group] Invoked. Action: '{action}', Email: '{user_google_email}'"
)
- try:
+ if action == "create":
+ if not name:
+ raise UserInputError("name is required for 'create' action.")
+
body = {"contactGroup": {"name": name}}
result = await asyncio.to_thread(
@@ -1123,58 +939,31 @@ async def create_contact_group(
)
resource_name = result.get("resourceName", "")
- group_id = resource_name.replace("contactGroups/", "")
+ created_group_id = resource_name.replace("contactGroups/", "")
created_name = result.get("name", name)
response = f"Contact Group Created for {user_google_email}:\n\n"
response += f"Name: {created_name}\n"
- response += f"ID: {group_id}\n"
+ response += f"ID: {created_group_id}\n"
response += f"Type: {result.get('groupType', 'USER_CONTACT_GROUP')}\n"
logger.info(f"Created contact group '{name}' for {user_google_email}")
return response
- except HttpError as error:
- message = f"API error: {error}. You might need to re-authenticate. LLM: Try 'start_google_auth' with the user's email ({user_google_email}) and service_name='Google Contacts'."
- logger.error(message, exc_info=True)
- raise Exception(message)
- except Exception as e:
- message = f"Unexpected error: {e}."
- logger.exception(message)
- raise Exception(message)
+ # All other actions require group_id
+ if not group_id:
+ raise UserInputError(f"group_id is required for '{action}' action.")
-
-@server.tool()
-@require_google_service("people", "contacts")
-@handle_http_errors("update_contact_group", service_type="people")
-async def update_contact_group(
- service: Resource,
- user_google_email: str,
- group_id: str,
- name: str,
-) -> str:
- """
- Update a contact group's name.
-
- Args:
- user_google_email (str): The user's Google email address. Required.
- group_id (str): The contact group ID to update.
- name (str): The new name for the contact group.
-
- Returns:
- str: Confirmation with updated group details.
- """
# Normalize resource name
if not group_id.startswith("contactGroups/"):
resource_name = f"contactGroups/{group_id}"
else:
resource_name = group_id
- logger.info(
- f"[update_contact_group] Invoked. Email: '{user_google_email}', Group: {resource_name}"
- )
+ if action == "update":
+ if not name:
+ raise UserInputError("name is required for 'update' action.")
- try:
body = {"contactGroup": {"name": name}}
result = await asyncio.to_thread(
@@ -1192,51 +981,7 @@ async def update_contact_group(
logger.info(f"Updated contact group {resource_name} for {user_google_email}")
return response
- except HttpError as error:
- if error.resp.status == 404:
- message = f"Contact group not found: {group_id}"
- logger.warning(message)
- raise Exception(message)
- message = f"API error: {error}. You might need to re-authenticate. LLM: Try 'start_google_auth' with the user's email ({user_google_email}) and service_name='Google Contacts'."
- logger.error(message, exc_info=True)
- raise Exception(message)
- except Exception as e:
- message = f"Unexpected error: {e}."
- logger.exception(message)
- raise Exception(message)
-
-
-@server.tool()
-@require_google_service("people", "contacts")
-@handle_http_errors("delete_contact_group", service_type="people")
-async def delete_contact_group(
- service: Resource,
- user_google_email: str,
- group_id: str,
- delete_contacts: bool = False,
-) -> str:
- """
- Delete a contact group.
-
- Args:
- user_google_email (str): The user's Google email address. Required.
- group_id (str): The contact group ID to delete.
- delete_contacts (bool): If True, also delete contacts in the group (default: False).
-
- Returns:
- str: Confirmation message.
- """
- # Normalize resource name
- if not group_id.startswith("contactGroups/"):
- resource_name = f"contactGroups/{group_id}"
- else:
- resource_name = group_id
-
- logger.info(
- f"[delete_contact_group] Invoked. Email: '{user_google_email}', Group: {resource_name}"
- )
-
- try:
+ if action == "delete":
await asyncio.to_thread(
service.contactGroups()
.delete(resourceName=resource_name, deleteContacts=delete_contacts)
@@ -1252,117 +997,56 @@ async def delete_contact_group(
logger.info(f"Deleted contact group {resource_name} for {user_google_email}")
return response
- except HttpError as error:
- if error.resp.status == 404:
- message = f"Contact group not found: {group_id}"
- logger.warning(message)
- raise Exception(message)
- message = f"API error: {error}. You might need to re-authenticate. LLM: Try 'start_google_auth' with the user's email ({user_google_email}) and service_name='Google Contacts'."
- logger.error(message, exc_info=True)
- raise Exception(message)
- except Exception as e:
- message = f"Unexpected error: {e}."
- logger.exception(message)
- raise Exception(message)
+ # action == "modify_members"
+ if not add_contact_ids and not remove_contact_ids:
+ raise UserInputError(
+ "At least one of add_contact_ids or remove_contact_ids must be provided."
+ )
+ modify_body: Dict[str, Any] = {}
-@server.tool()
-@require_google_service("people", "contacts")
-@handle_http_errors("modify_contact_group_members", service_type="people")
-async def modify_contact_group_members(
- service: Resource,
- user_google_email: str,
- group_id: str,
- add_contact_ids: Optional[List[str]] = None,
- remove_contact_ids: Optional[List[str]] = None,
-) -> str:
- """
- Add or remove contacts from a contact group.
+ if add_contact_ids:
+ add_names = []
+ for contact_id in add_contact_ids:
+ if not contact_id.startswith("people/"):
+ add_names.append(f"people/{contact_id}")
+ else:
+ add_names.append(contact_id)
+ modify_body["resourceNamesToAdd"] = add_names
- Args:
- user_google_email (str): The user's Google email address. Required.
- group_id (str): The contact group ID.
- add_contact_ids (Optional[List[str]]): Contact IDs to add to the group.
- remove_contact_ids (Optional[List[str]]): Contact IDs to remove from the group.
+ if remove_contact_ids:
+ remove_names = []
+ for contact_id in remove_contact_ids:
+ if not contact_id.startswith("people/"):
+ remove_names.append(f"people/{contact_id}")
+ else:
+ remove_names.append(contact_id)
+ modify_body["resourceNamesToRemove"] = remove_names
- Returns:
- str: Confirmation with results.
- """
- # Normalize resource name
- if not group_id.startswith("contactGroups/"):
- resource_name = f"contactGroups/{group_id}"
- else:
- resource_name = group_id
-
- logger.info(
- f"[modify_contact_group_members] Invoked. Email: '{user_google_email}', Group: {resource_name}"
+ result = await asyncio.to_thread(
+ service.contactGroups()
+ .members()
+ .modify(resourceName=resource_name, body=modify_body)
+ .execute
)
- try:
- if not add_contact_ids and not remove_contact_ids:
- raise Exception(
- "At least one of add_contact_ids or remove_contact_ids must be provided."
- )
+ not_found = result.get("notFoundResourceNames", [])
+ cannot_remove = result.get("canNotRemoveLastContactGroupResourceNames", [])
- body: Dict[str, Any] = {}
+ response = f"Contact Group Members Modified for {user_google_email}:\n\n"
+ response += f"Group: {group_id}\n"
- if add_contact_ids:
- # Normalize resource names
- add_names = []
- for contact_id in add_contact_ids:
- if not contact_id.startswith("people/"):
- add_names.append(f"people/{contact_id}")
- else:
- add_names.append(contact_id)
- body["resourceNamesToAdd"] = add_names
+ if add_contact_ids:
+ response += f"Added: {len(add_contact_ids)} contacts\n"
+ if remove_contact_ids:
+ response += f"Removed: {len(remove_contact_ids)} contacts\n"
- if remove_contact_ids:
- # Normalize resource names
- remove_names = []
- for contact_id in remove_contact_ids:
- if not contact_id.startswith("people/"):
- remove_names.append(f"people/{contact_id}")
- else:
- remove_names.append(contact_id)
- body["resourceNamesToRemove"] = remove_names
+ if not_found:
+ response += f"\nNot found: {', '.join(not_found)}\n"
+ if cannot_remove:
+ response += f"\nCannot remove (last group): {', '.join(cannot_remove)}\n"
- result = await asyncio.to_thread(
- service.contactGroups()
- .members()
- .modify(resourceName=resource_name, body=body)
- .execute
- )
-
- not_found = result.get("notFoundResourceNames", [])
- cannot_remove = result.get("canNotRemoveLastContactGroupResourceNames", [])
-
- response = f"Contact Group Members Modified for {user_google_email}:\n\n"
- response += f"Group: {group_id}\n"
-
- if add_contact_ids:
- response += f"Added: {len(add_contact_ids)} contacts\n"
- if remove_contact_ids:
- response += f"Removed: {len(remove_contact_ids)} contacts\n"
-
- if not_found:
- response += f"\nNot found: {', '.join(not_found)}\n"
- if cannot_remove:
- response += f"\nCannot remove (last group): {', '.join(cannot_remove)}\n"
-
- logger.info(
- f"Modified contact group members for {resource_name} for {user_google_email}"
- )
- return response
-
- except HttpError as error:
- if error.resp.status == 404:
- message = f"Contact group not found: {group_id}"
- logger.warning(message)
- raise Exception(message)
- message = f"API error: {error}. You might need to re-authenticate. LLM: Try 'start_google_auth' with the user's email ({user_google_email}) and service_name='Google Contacts'."
- logger.error(message, exc_info=True)
- raise Exception(message)
- except Exception as e:
- message = f"Unexpected error: {e}."
- logger.exception(message)
- raise Exception(message)
+ logger.info(
+ f"Modified contact group members for {resource_name} for {user_google_email}"
+ )
+ return response
diff --git a/gdocs/docs_markdown.py b/gdocs/docs_markdown.py
index 41a69d1..d9c183d 100644
--- a/gdocs/docs_markdown.py
+++ b/gdocs/docs_markdown.py
@@ -5,6 +5,7 @@ Converts Google Docs API JSON responses to clean Markdown, preserving:
- Headings (H1-H6, Title, Subtitle)
- Bold, italic, strikethrough, code, links
- Ordered and unordered lists with nesting
+- Checklists with checked/unchecked state
- Tables with header row separators
"""
@@ -60,9 +61,20 @@ def convert_doc_to_markdown(doc: dict[str, Any]) -> str:
if bullet:
list_id = bullet["listId"]
nesting = bullet.get("nestingLevel", 0)
- is_ordered = _is_ordered_list(lists_meta, list_id, nesting)
- if is_ordered:
+ if _is_checklist(lists_meta, list_id, nesting):
+ checked = _is_checked(para)
+ checkbox = "[x]" if checked else "[ ]"
+ indent = " " * nesting
+ # Re-render text without strikethrough for checked items
+ # to avoid redundant ~~text~~ alongside [x]
+ cb_text = (
+ _convert_paragraph_text(para, skip_strikethrough=True)
+ if checked
+ else text
+ )
+ lines.append(f"{indent}- {checkbox} {cb_text}")
+ elif _is_ordered_list(lists_meta, list_id, nesting):
key = (list_id, nesting)
ordered_counters[key] = ordered_counters.get(key, 0) + 1
counter = ordered_counters[key]
@@ -102,16 +114,20 @@ def convert_doc_to_markdown(doc: dict[str, Any]) -> str:
return result
-def _convert_paragraph_text(para: dict[str, Any]) -> str:
+def _convert_paragraph_text(
+ para: dict[str, Any], skip_strikethrough: bool = False
+) -> str:
"""Convert paragraph elements to inline markdown text."""
parts: list[str] = []
for elem in para.get("elements", []):
if "textRun" in elem:
- parts.append(_convert_text_run(elem["textRun"]))
+ parts.append(_convert_text_run(elem["textRun"], skip_strikethrough))
return "".join(parts).strip()
-def _convert_text_run(text_run: dict[str, Any]) -> str:
+def _convert_text_run(
+ text_run: dict[str, Any], skip_strikethrough: bool = False
+) -> str:
"""Convert a single text run to markdown."""
content = text_run.get("content", "")
style = text_run.get("textStyle", {})
@@ -120,10 +136,12 @@ def _convert_text_run(text_run: dict[str, Any]) -> str:
if not text:
return ""
- return _apply_text_style(text, style)
+ return _apply_text_style(text, style, skip_strikethrough)
-def _apply_text_style(text: str, style: dict[str, Any]) -> str:
+def _apply_text_style(
+ text: str, style: dict[str, Any], skip_strikethrough: bool = False
+) -> str:
"""Apply markdown formatting based on text style."""
link = style.get("link", {})
url = link.get("url")
@@ -143,7 +161,7 @@ def _apply_text_style(text: str, style: dict[str, Any]) -> str:
elif italic:
text = f"*{text}*"
- if strikethrough:
+ if strikethrough and not skip_strikethrough:
text = f"~~{text}~~"
if url:
@@ -163,6 +181,37 @@ def _is_ordered_list(lists_meta: dict[str, Any], list_id: str, nesting: int) ->
return False
+def _is_checklist(lists_meta: dict[str, Any], list_id: str, nesting: int) -> bool:
+ """Check if a list at a given nesting level is a checklist.
+
+ Google Docs checklists are distinguished from regular bullet lists by having
+ GLYPH_TYPE_UNSPECIFIED with no glyphSymbol — the Docs UI renders interactive
+ checkboxes rather than a static glyph character.
+ """
+ list_info = lists_meta.get(list_id, {})
+ nesting_levels = list_info.get("listProperties", {}).get("nestingLevels", [])
+ if nesting < len(nesting_levels):
+ level = nesting_levels[nesting]
+ glyph_type = level.get("glyphType", "")
+ has_glyph_symbol = "glyphSymbol" in level
+ return glyph_type in ("", "GLYPH_TYPE_UNSPECIFIED") and not has_glyph_symbol
+ return False
+
+
+def _is_checked(para: dict[str, Any]) -> bool:
+ """Check if a checklist item is checked.
+
+ Google Docs marks checked checklist items by applying strikethrough
+ formatting to the paragraph text.
+ """
+ for elem in para.get("elements", []):
+ if "textRun" in elem:
+ content = elem["textRun"].get("content", "").strip()
+ if content:
+ return elem["textRun"].get("textStyle", {}).get("strikethrough", False)
+ return False
+
+
def _convert_table(table: dict[str, Any]) -> str:
"""Convert a table element to markdown."""
rows = table.get("tableRows", [])
diff --git a/gdocs/docs_tools.py b/gdocs/docs_tools.py
index a044e06..218ccbd 100644
--- a/gdocs/docs_tools.py
+++ b/gdocs/docs_tools.py
@@ -1678,7 +1678,5 @@ async def get_doc_as_markdown(
_comment_tools = create_comment_tools("document", "document_id")
# Extract and register the functions
-read_doc_comments = _comment_tools["read_comments"]
-create_doc_comment = _comment_tools["create_comment"]
-reply_to_comment = _comment_tools["reply_to_comment"]
-resolve_comment = _comment_tools["resolve_comment"]
+list_document_comments = _comment_tools["list_comments"]
+manage_document_comment = _comment_tools["manage_comment"]
diff --git a/gdrive/drive_helpers.py b/gdrive/drive_helpers.py
index db6ec40..55e342a 100644
--- a/gdrive/drive_helpers.py
+++ b/gdrive/drive_helpers.py
@@ -305,6 +305,7 @@ def resolve_file_type_mime(file_type: str) -> str:
)
return FILE_TYPE_MIME_MAP[lower]
+
BASE_SHORTCUT_FIELDS = (
"id, mimeType, parents, shortcutDetails(targetId, targetMimeType)"
)
diff --git a/gdrive/drive_tools.py b/gdrive/drive_tools.py
index 8f34576..f2120e8 100644
--- a/gdrive/drive_tools.py
+++ b/gdrive/drive_tools.py
@@ -1774,365 +1774,368 @@ async def get_drive_shareable_link(
@server.tool()
-@handle_http_errors("share_drive_file", is_read_only=False, service_type="drive")
+@handle_http_errors("manage_drive_access", is_read_only=False, service_type="drive")
@require_google_service("drive", "drive_file")
-async def share_drive_file(
+async def manage_drive_access(
service,
user_google_email: str,
file_id: str,
+ action: str,
share_with: Optional[str] = None,
- role: str = "reader",
+ role: Optional[str] = None,
share_type: str = "user",
+ permission_id: Optional[str] = None,
+ recipients: Optional[List[Dict[str, Any]]] = None,
send_notification: bool = True,
email_message: Optional[str] = None,
expiration_time: Optional[str] = None,
allow_file_discovery: Optional[bool] = None,
+ new_owner_email: Optional[str] = None,
+ move_to_new_owners_root: bool = False,
) -> str:
"""
- Shares a Google Drive file or folder with a user, group, domain, or anyone with the link.
+ Consolidated tool for managing Google Drive file and folder access permissions.
- When sharing a folder, all files inside inherit the permission.
+ Supports granting, batch-granting, updating, revoking permissions, and
+ transferring file ownership -- all through a single entry point.
Args:
user_google_email (str): The user's Google email address. Required.
- file_id (str): The ID of the file or folder to share. Required.
- share_with (Optional[str]): Email address (for user/group), domain name (for domain), or omit for 'anyone'.
- role (str): Permission role - 'reader', 'commenter', or 'writer'. Defaults to 'reader'.
- share_type (str): Type of sharing - 'user', 'group', 'domain', or 'anyone'. Defaults to 'user'.
- send_notification (bool): Whether to send a notification email. Defaults to True.
- email_message (Optional[str]): Custom message for the notification email.
- expiration_time (Optional[str]): Expiration time in RFC 3339 format (e.g., "2025-01-15T00:00:00Z"). Permission auto-revokes after this time.
- allow_file_discovery (Optional[bool]): For 'domain' or 'anyone' shares - whether the file can be found via search. Defaults to None (API default).
-
- Returns:
- str: Confirmation with permission details and shareable link.
- """
- logger.info(
- f"[share_drive_file] Invoked. Email: '{user_google_email}', File ID: '{file_id}', Share with: '{share_with}', Role: '{role}', Type: '{share_type}'"
- )
-
- validate_share_role(role)
- validate_share_type(share_type)
-
- if share_type in ("user", "group") and not share_with:
- raise ValueError(f"share_with is required for share_type '{share_type}'")
- if share_type == "domain" and not share_with:
- raise ValueError("share_with (domain name) is required for share_type 'domain'")
-
- resolved_file_id, file_metadata = await resolve_drive_item(
- service, file_id, extra_fields="name, webViewLink"
- )
- file_id = resolved_file_id
-
- permission_body = {
- "type": share_type,
- "role": role,
- }
-
- if share_type in ("user", "group"):
- permission_body["emailAddress"] = share_with
- elif share_type == "domain":
- permission_body["domain"] = share_with
-
- if expiration_time:
- validate_expiration_time(expiration_time)
- permission_body["expirationTime"] = expiration_time
-
- if share_type in ("domain", "anyone") and allow_file_discovery is not None:
- permission_body["allowFileDiscovery"] = allow_file_discovery
-
- create_params = {
- "fileId": file_id,
- "body": permission_body,
- "supportsAllDrives": True,
- "fields": "id, type, role, emailAddress, domain, expirationTime",
- }
-
- if share_type in ("user", "group"):
- create_params["sendNotificationEmail"] = send_notification
- if email_message:
- create_params["emailMessage"] = email_message
-
- created_permission = await asyncio.to_thread(
- service.permissions().create(**create_params).execute
- )
-
- output_parts = [
- f"Successfully shared '{file_metadata.get('name', 'Unknown')}'",
- "",
- "Permission created:",
- f" - {format_permission_info(created_permission)}",
- "",
- f"View link: {file_metadata.get('webViewLink', 'N/A')}",
- ]
-
- return "\n".join(output_parts)
-
-
-@server.tool()
-@handle_http_errors("batch_share_drive_file", is_read_only=False, service_type="drive")
-@require_google_service("drive", "drive_file")
-async def batch_share_drive_file(
- service,
- user_google_email: str,
- file_id: str,
- recipients: List[Dict[str, Any]],
- send_notification: bool = True,
- email_message: Optional[str] = None,
-) -> str:
- """
- Shares a Google Drive file or folder with multiple users or groups in a single operation.
-
- Each recipient can have a different role and optional expiration time.
-
- Note: Each recipient is processed sequentially. For very large recipient lists,
- consider splitting into multiple calls.
-
- Args:
- user_google_email (str): The user's Google email address. Required.
- file_id (str): The ID of the file or folder to share. Required.
- recipients (List[Dict]): List of recipient objects. Each should have:
- - email (str): Recipient email address. Required for 'user' or 'group' share_type.
- - role (str): Permission role - 'reader', 'commenter', or 'writer'. Defaults to 'reader'.
- - share_type (str, optional): 'user', 'group', or 'domain'. Defaults to 'user'.
- - expiration_time (str, optional): Expiration in RFC 3339 format (e.g., "2025-01-15T00:00:00Z").
- For domain shares, use 'domain' field instead of 'email':
- - domain (str): Domain name. Required when share_type is 'domain'.
+ file_id (str): The ID of the file or folder. Required.
+ action (str): The access management action to perform. Required. One of:
+ - "grant": Share with a single user, group, domain, or anyone.
+ - "grant_batch": Share with multiple recipients in one call.
+ - "update": Modify an existing permission (role or expiration).
+ - "revoke": Remove an existing permission.
+ - "transfer_owner": Transfer file ownership to another user.
+ share_with (Optional[str]): Email address (user/group), domain name (domain),
+ or omit for 'anyone'. Used by "grant".
+ role (Optional[str]): Permission role -- 'reader', 'commenter', or 'writer'.
+ Used by "grant" (defaults to 'reader') and "update".
+ share_type (str): Type of sharing -- 'user', 'group', 'domain', or 'anyone'.
+ Used by "grant". Defaults to 'user'.
+ permission_id (Optional[str]): The permission ID to modify or remove.
+ Required for "update" and "revoke" actions.
+ recipients (Optional[List[Dict[str, Any]]]): List of recipient objects for
+ "grant_batch". Each should have: email (str), role (str, optional),
+ share_type (str, optional), expiration_time (str, optional). For domain
+ shares use 'domain' field instead of 'email'.
send_notification (bool): Whether to send notification emails. Defaults to True.
- email_message (Optional[str]): Custom message for notification emails.
+ Used by "grant" and "grant_batch".
+ email_message (Optional[str]): Custom notification email message.
+ Used by "grant" and "grant_batch".
+ expiration_time (Optional[str]): Expiration in RFC 3339 format
+ (e.g., "2025-01-15T00:00:00Z"). Used by "grant" and "update".
+ allow_file_discovery (Optional[bool]): For 'domain'/'anyone' shares, whether
+ the file appears in search. Used by "grant".
+ new_owner_email (Optional[str]): Email of the new owner.
+ Required for "transfer_owner".
+ move_to_new_owners_root (bool): Move file to the new owner's My Drive root.
+ Defaults to False. Used by "transfer_owner".
Returns:
- str: Summary of created permissions with success/failure for each recipient.
+ str: Confirmation with details of the permission change applied.
"""
+ valid_actions = ("grant", "grant_batch", "update", "revoke", "transfer_owner")
+ if action not in valid_actions:
+ raise ValueError(
+ f"Invalid action '{action}'. Must be one of: {', '.join(valid_actions)}"
+ )
+
logger.info(
- f"[batch_share_drive_file] Invoked. Email: '{user_google_email}', File ID: '{file_id}', Recipients: {len(recipients)}"
+ f"[manage_drive_access] Invoked. Email: '{user_google_email}', "
+ f"File ID: '{file_id}', Action: '{action}'"
)
- resolved_file_id, file_metadata = await resolve_drive_item(
- service, file_id, extra_fields="name, webViewLink"
- )
- file_id = resolved_file_id
+ # --- grant: share with a single recipient ---
+ if action == "grant":
+ effective_role = role or "reader"
+ validate_share_role(effective_role)
+ validate_share_type(share_type)
- if not recipients:
- raise ValueError("recipients list cannot be empty")
+ if share_type in ("user", "group") and not share_with:
+ raise ValueError(f"share_with is required for share_type '{share_type}'")
+ if share_type == "domain" and not share_with:
+ raise ValueError(
+ "share_with (domain name) is required for share_type 'domain'"
+ )
- results = []
- success_count = 0
- failure_count = 0
+ resolved_file_id, file_metadata = await resolve_drive_item(
+ service, file_id, extra_fields="name, webViewLink"
+ )
+ file_id = resolved_file_id
- for recipient in recipients:
- share_type = recipient.get("share_type", "user")
-
- if share_type == "domain":
- domain = recipient.get("domain")
- if not domain:
- results.append(" - Skipped: missing domain for domain share")
- failure_count += 1
- continue
- identifier = domain
- else:
- email = recipient.get("email")
- if not email:
- results.append(" - Skipped: missing email address")
- failure_count += 1
- continue
- identifier = email
-
- role = recipient.get("role", "reader")
- try:
- validate_share_role(role)
- except ValueError as e:
- results.append(f" - {identifier}: Failed - {e}")
- failure_count += 1
- continue
-
- try:
- validate_share_type(share_type)
- except ValueError as e:
- results.append(f" - {identifier}: Failed - {e}")
- failure_count += 1
- continue
-
- permission_body = {
+ permission_body: Dict[str, Any] = {
"type": share_type,
- "role": role,
+ "role": effective_role,
}
+ if share_type in ("user", "group"):
+ permission_body["emailAddress"] = share_with
+ elif share_type == "domain":
+ permission_body["domain"] = share_with
- if share_type == "domain":
- permission_body["domain"] = identifier
- else:
- permission_body["emailAddress"] = identifier
+ if expiration_time:
+ validate_expiration_time(expiration_time)
+ permission_body["expirationTime"] = expiration_time
- if recipient.get("expiration_time"):
- try:
- validate_expiration_time(recipient["expiration_time"])
- permission_body["expirationTime"] = recipient["expiration_time"]
- except ValueError as e:
- results.append(f" - {identifier}: Failed - {e}")
- failure_count += 1
- continue
+ if share_type in ("domain", "anyone") and allow_file_discovery is not None:
+ permission_body["allowFileDiscovery"] = allow_file_discovery
- create_params = {
+ create_params: Dict[str, Any] = {
"fileId": file_id,
"body": permission_body,
"supportsAllDrives": True,
"fields": "id, type, role, emailAddress, domain, expirationTime",
}
-
if share_type in ("user", "group"):
create_params["sendNotificationEmail"] = send_notification
if email_message:
create_params["emailMessage"] = email_message
- try:
- created_permission = await asyncio.to_thread(
- service.permissions().create(**create_params).execute
- )
- results.append(f" - {format_permission_info(created_permission)}")
- success_count += 1
- except HttpError as e:
- results.append(f" - {identifier}: Failed - {str(e)}")
- failure_count += 1
+ created_permission = await asyncio.to_thread(
+ service.permissions().create(**create_params).execute
+ )
- output_parts = [
- f"Batch share results for '{file_metadata.get('name', 'Unknown')}'",
- "",
- f"Summary: {success_count} succeeded, {failure_count} failed",
- "",
- "Results:",
- ]
- output_parts.extend(results)
- output_parts.extend(
- [
+ return "\n".join(
+ [
+ f"Successfully shared '{file_metadata.get('name', 'Unknown')}'",
+ "",
+ "Permission created:",
+ f" - {format_permission_info(created_permission)}",
+ "",
+ f"View link: {file_metadata.get('webViewLink', 'N/A')}",
+ ]
+ )
+
+ # --- grant_batch: share with multiple recipients ---
+ if action == "grant_batch":
+ if not recipients:
+ raise ValueError("recipients list is required for 'grant_batch' action")
+
+ resolved_file_id, file_metadata = await resolve_drive_item(
+ service, file_id, extra_fields="name, webViewLink"
+ )
+ file_id = resolved_file_id
+
+ results: List[str] = []
+ success_count = 0
+ failure_count = 0
+
+ for recipient in recipients:
+ r_share_type = recipient.get("share_type", "user")
+
+ if r_share_type == "domain":
+ domain = recipient.get("domain")
+ if not domain:
+ results.append(" - Skipped: missing domain for domain share")
+ failure_count += 1
+ continue
+ identifier = domain
+ else:
+ r_email = recipient.get("email")
+ if not r_email:
+ results.append(" - Skipped: missing email address")
+ failure_count += 1
+ continue
+ identifier = r_email
+
+ r_role = recipient.get("role", "reader")
+ try:
+ validate_share_role(r_role)
+ except ValueError as e:
+ results.append(f" - {identifier}: Failed - {e}")
+ failure_count += 1
+ continue
+
+ try:
+ validate_share_type(r_share_type)
+ except ValueError as e:
+ results.append(f" - {identifier}: Failed - {e}")
+ failure_count += 1
+ continue
+
+ r_perm_body: Dict[str, Any] = {
+ "type": r_share_type,
+ "role": r_role,
+ }
+ if r_share_type == "domain":
+ r_perm_body["domain"] = identifier
+ else:
+ r_perm_body["emailAddress"] = identifier
+
+ if recipient.get("expiration_time"):
+ try:
+ validate_expiration_time(recipient["expiration_time"])
+ r_perm_body["expirationTime"] = recipient["expiration_time"]
+ except ValueError as e:
+ results.append(f" - {identifier}: Failed - {e}")
+ failure_count += 1
+ continue
+
+ r_create_params: Dict[str, Any] = {
+ "fileId": file_id,
+ "body": r_perm_body,
+ "supportsAllDrives": True,
+ "fields": "id, type, role, emailAddress, domain, expirationTime",
+ }
+ if r_share_type in ("user", "group"):
+ r_create_params["sendNotificationEmail"] = send_notification
+ if email_message:
+ r_create_params["emailMessage"] = email_message
+
+ try:
+ created_perm = await asyncio.to_thread(
+ service.permissions().create(**r_create_params).execute
+ )
+ results.append(f" - {format_permission_info(created_perm)}")
+ success_count += 1
+ except HttpError as e:
+ results.append(f" - {identifier}: Failed - {str(e)}")
+ failure_count += 1
+
+ output_parts = [
+ f"Batch share results for '{file_metadata.get('name', 'Unknown')}'",
"",
- f"View link: {file_metadata.get('webViewLink', 'N/A')}",
+ f"Summary: {success_count} succeeded, {failure_count} failed",
+ "",
+ "Results:",
]
- )
+ output_parts.extend(results)
+ output_parts.extend(
+ [
+ "",
+ f"View link: {file_metadata.get('webViewLink', 'N/A')}",
+ ]
+ )
+ return "\n".join(output_parts)
- return "\n".join(output_parts)
+ # --- update: modify an existing permission ---
+ if action == "update":
+ if not permission_id:
+ raise ValueError("permission_id is required for 'update' action")
+ if not role and not expiration_time:
+ raise ValueError(
+ "Must provide at least one of: role, expiration_time for 'update' action"
+ )
+ if role:
+ validate_share_role(role)
+ if expiration_time:
+ validate_expiration_time(expiration_time)
-@server.tool()
-@handle_http_errors("update_drive_permission", is_read_only=False, service_type="drive")
-@require_google_service("drive", "drive_file")
-async def update_drive_permission(
- service,
- user_google_email: str,
- file_id: str,
- permission_id: str,
- role: Optional[str] = None,
- expiration_time: Optional[str] = None,
-) -> str:
- """
- Updates an existing permission on a Google Drive file or folder.
+ resolved_file_id, file_metadata = await resolve_drive_item(
+ service, file_id, extra_fields="name"
+ )
+ file_id = resolved_file_id
- Args:
- user_google_email (str): The user's Google email address. Required.
- file_id (str): The ID of the file or folder. Required.
- permission_id (str): The ID of the permission to update (from get_drive_file_permissions). Required.
- role (Optional[str]): New role - 'reader', 'commenter', or 'writer'. If not provided, role unchanged.
- expiration_time (Optional[str]): Expiration time in RFC 3339 format (e.g., "2025-01-15T00:00:00Z"). Set or update when permission expires.
+ effective_role = role
+ if not effective_role:
+ current_permission = await asyncio.to_thread(
+ service.permissions()
+ .get(
+ fileId=file_id,
+ permissionId=permission_id,
+ supportsAllDrives=True,
+ fields="role",
+ )
+ .execute
+ )
+ effective_role = current_permission.get("role")
- Returns:
- str: Confirmation with updated permission details.
- """
- logger.info(
- f"[update_drive_permission] Invoked. Email: '{user_google_email}', File ID: '{file_id}', Permission ID: '{permission_id}', Role: '{role}'"
- )
+ update_body: Dict[str, Any] = {"role": effective_role}
+ if expiration_time:
+ update_body["expirationTime"] = expiration_time
- if not role and not expiration_time:
- raise ValueError("Must provide at least one of: role, expiration_time")
-
- if role:
- validate_share_role(role)
- if expiration_time:
- validate_expiration_time(expiration_time)
-
- resolved_file_id, file_metadata = await resolve_drive_item(
- service, file_id, extra_fields="name"
- )
- file_id = resolved_file_id
-
- # Google API requires role in update body, so fetch current if not provided
- if not role:
- current_permission = await asyncio.to_thread(
+ updated_permission = await asyncio.to_thread(
service.permissions()
- .get(
+ .update(
fileId=file_id,
permissionId=permission_id,
+ body=update_body,
supportsAllDrives=True,
- fields="role",
+ fields="id, type, role, emailAddress, domain, expirationTime",
)
.execute
)
- role = current_permission.get("role")
- update_body = {"role": role}
- if expiration_time:
- update_body["expirationTime"] = expiration_time
+ return "\n".join(
+ [
+ f"Successfully updated permission on '{file_metadata.get('name', 'Unknown')}'",
+ "",
+ "Updated permission:",
+ f" - {format_permission_info(updated_permission)}",
+ ]
+ )
- updated_permission = await asyncio.to_thread(
+ # --- revoke: remove an existing permission ---
+ if action == "revoke":
+ if not permission_id:
+ raise ValueError("permission_id is required for 'revoke' action")
+
+ resolved_file_id, file_metadata = await resolve_drive_item(
+ service, file_id, extra_fields="name"
+ )
+ file_id = resolved_file_id
+
+ await asyncio.to_thread(
+ service.permissions()
+ .delete(
+ fileId=file_id,
+ permissionId=permission_id,
+ supportsAllDrives=True,
+ )
+ .execute
+ )
+
+ return "\n".join(
+ [
+ f"Successfully removed permission from '{file_metadata.get('name', 'Unknown')}'",
+ "",
+ f"Permission ID '{permission_id}' has been revoked.",
+ ]
+ )
+
+ # --- transfer_owner: transfer file ownership ---
+ # action == "transfer_owner"
+ if not new_owner_email:
+ raise ValueError("new_owner_email is required for 'transfer_owner' action")
+
+ resolved_file_id, file_metadata = await resolve_drive_item(
+ service, file_id, extra_fields="name, owners"
+ )
+ file_id = resolved_file_id
+
+ current_owners = file_metadata.get("owners", [])
+ current_owner_emails = [o.get("emailAddress", "") for o in current_owners]
+
+ transfer_body: Dict[str, Any] = {
+ "type": "user",
+ "role": "owner",
+ "emailAddress": new_owner_email,
+ }
+
+ await asyncio.to_thread(
service.permissions()
- .update(
+ .create(
fileId=file_id,
- permissionId=permission_id,
- body=update_body,
+ body=transfer_body,
+ transferOwnership=True,
+ moveToNewOwnersRoot=move_to_new_owners_root,
supportsAllDrives=True,
- fields="id, type, role, emailAddress, domain, expirationTime",
+ fields="id, type, role, emailAddress",
)
.execute
)
output_parts = [
- f"Successfully updated permission on '{file_metadata.get('name', 'Unknown')}'",
+ f"Successfully transferred ownership of '{file_metadata.get('name', 'Unknown')}'",
"",
- "Updated permission:",
- f" - {format_permission_info(updated_permission)}",
- ]
-
- return "\n".join(output_parts)
-
-
-@server.tool()
-@handle_http_errors("remove_drive_permission", is_read_only=False, service_type="drive")
-@require_google_service("drive", "drive_file")
-async def remove_drive_permission(
- service,
- user_google_email: str,
- file_id: str,
- permission_id: str,
-) -> str:
- """
- Removes a permission from a Google Drive file or folder, revoking access.
-
- Args:
- user_google_email (str): The user's Google email address. Required.
- file_id (str): The ID of the file or folder. Required.
- permission_id (str): The ID of the permission to remove (from get_drive_file_permissions). Required.
-
- Returns:
- str: Confirmation of the removed permission.
- """
- logger.info(
- f"[remove_drive_permission] Invoked. Email: '{user_google_email}', File ID: '{file_id}', Permission ID: '{permission_id}'"
- )
-
- resolved_file_id, file_metadata = await resolve_drive_item(
- service, file_id, extra_fields="name"
- )
- file_id = resolved_file_id
-
- await asyncio.to_thread(
- service.permissions()
- .delete(fileId=file_id, permissionId=permission_id, supportsAllDrives=True)
- .execute
- )
-
- output_parts = [
- f"Successfully removed permission from '{file_metadata.get('name', 'Unknown')}'",
- "",
- f"Permission ID '{permission_id}' has been revoked.",
+ f"New owner: {new_owner_email}",
+ f"Previous owner(s): {', '.join(current_owner_emails) or 'Unknown'}",
]
+ if move_to_new_owners_root:
+ output_parts.append(f"File moved to {new_owner_email}'s My Drive root.")
+ output_parts.extend(["", "Note: Previous owner now has editor access."])
return "\n".join(output_parts)
@@ -2209,79 +2212,6 @@ async def copy_drive_file(
return "\n".join(output_parts)
-@server.tool()
-@handle_http_errors(
- "transfer_drive_ownership", is_read_only=False, service_type="drive"
-)
-@require_google_service("drive", "drive_file")
-async def transfer_drive_ownership(
- service,
- user_google_email: str,
- file_id: str,
- new_owner_email: str,
- move_to_new_owners_root: bool = False,
-) -> str:
- """
- Transfers ownership of a Google Drive file or folder to another user.
-
- This is an irreversible operation. The current owner will become an editor.
- Only works within the same Google Workspace domain or for personal accounts.
-
- Args:
- user_google_email (str): The user's Google email address. Required.
- file_id (str): The ID of the file or folder to transfer. Required.
- new_owner_email (str): Email address of the new owner. Required.
- move_to_new_owners_root (bool): If True, moves the file to the new owner's My Drive root. Defaults to False.
-
- Returns:
- str: Confirmation of the ownership transfer.
- """
- logger.info(
- f"[transfer_drive_ownership] Invoked. Email: '{user_google_email}', File ID: '{file_id}', New owner: '{new_owner_email}'"
- )
-
- resolved_file_id, file_metadata = await resolve_drive_item(
- service, file_id, extra_fields="name, owners"
- )
- file_id = resolved_file_id
-
- current_owners = file_metadata.get("owners", [])
- current_owner_emails = [o.get("emailAddress", "") for o in current_owners]
-
- permission_body = {
- "type": "user",
- "role": "owner",
- "emailAddress": new_owner_email,
- }
-
- await asyncio.to_thread(
- service.permissions()
- .create(
- fileId=file_id,
- body=permission_body,
- transferOwnership=True,
- moveToNewOwnersRoot=move_to_new_owners_root,
- supportsAllDrives=True,
- fields="id, type, role, emailAddress",
- )
- .execute
- )
-
- output_parts = [
- f"Successfully transferred ownership of '{file_metadata.get('name', 'Unknown')}'",
- "",
- f"New owner: {new_owner_email}",
- f"Previous owner(s): {', '.join(current_owner_emails) or 'Unknown'}",
- ]
-
- if move_to_new_owners_root:
- output_parts.append(f"File moved to {new_owner_email}'s My Drive root.")
-
- output_parts.extend(["", "Note: Previous owner now has editor access."])
-
- return "\n".join(output_parts)
-
-
@server.tool()
@handle_http_errors(
"set_drive_file_permissions", is_read_only=False, service_type="drive"
diff --git a/gmail/gmail_tools.py b/gmail/gmail_tools.py
index a56034a..ff875d5 100644
--- a/gmail/gmail_tools.py
+++ b/gmail/gmail_tools.py
@@ -19,9 +19,10 @@ from email import encoders
from email.utils import formataddr
from pydantic import Field
+from googleapiclient.errors import HttpError
from auth.service_decorator import require_google_service
-from core.utils import handle_http_errors, validate_file_path
+from core.utils import handle_http_errors, validate_file_path, UserInputError
from core.server import server
from auth.scopes import (
GMAIL_SEND_SCOPE,
@@ -172,6 +173,74 @@ def _format_body_content(text_body: str, html_body: str) -> str:
return "[No readable content found]"
+def _append_signature_to_body(
+ body: str, body_format: Literal["plain", "html"], signature_html: str
+) -> str:
+ """Append a Gmail signature to the outgoing body, preserving body format."""
+ if not signature_html or not signature_html.strip():
+ return body
+
+ if body_format == "html":
+ separator = "
" if body.strip() else ""
+ return f"{body}{separator}{signature_html}"
+
+ signature_text = _html_to_text(signature_html).strip()
+ if not signature_text:
+ return body
+ separator = "\n\n" if body.strip() else ""
+ return f"{body}{separator}{signature_text}"
+
+
+async def _get_send_as_signature_html(service, from_email: Optional[str] = None) -> str:
+ """
+ Fetch signature HTML from Gmail send-as settings.
+
+ Returns empty string when the account has no signature configured or the
+ OAuth token cannot access settings endpoints.
+ """
+ try:
+ response = await asyncio.to_thread(
+ service.users().settings().sendAs().list(userId="me").execute
+ )
+ except HttpError as e:
+ status = getattr(getattr(e, "resp", None), "status", None)
+ if status in {401, 403}:
+ logger.info(
+ "Skipping Gmail signature fetch: missing auth/scope for settings endpoint."
+ )
+ return ""
+ logger.warning(f"Failed to fetch Gmail send-as signatures: {e}")
+ return ""
+ except Exception as e:
+ logger.warning(f"Failed to fetch Gmail send-as signatures: {e}")
+ return ""
+
+ send_as_entries = response.get("sendAs", [])
+ if not send_as_entries:
+ return ""
+
+ if from_email:
+ from_email_normalized = from_email.strip().lower()
+ for entry in send_as_entries:
+ if entry.get("sendAsEmail", "").strip().lower() == from_email_normalized:
+ return entry.get("signature", "") or ""
+
+ for entry in send_as_entries:
+ if entry.get("isPrimary"):
+ return entry.get("signature", "") or ""
+
+ return send_as_entries[0].get("signature", "") or ""
+
+
+def _format_attachment_result(attached_count: int, requested_count: int) -> str:
+ """Format attachment result message for user-facing responses."""
+ if requested_count <= 0:
+ return ""
+ if attached_count == requested_count:
+ return f" with {attached_count} attachment(s)"
+ return f" with {attached_count}/{requested_count} attachment(s) attached"
+
+
def _extract_attachments(payload: dict) -> List[Dict[str, Any]]:
"""
Extract attachment metadata from a Gmail message payload.
@@ -241,7 +310,7 @@ def _prepare_gmail_message(
from_email: Optional[str] = None,
from_name: Optional[str] = None,
attachments: Optional[List[Dict[str, str]]] = None,
-) -> tuple[str, Optional[str]]:
+) -> tuple[str, Optional[str], int]:
"""
Prepare a Gmail message with threading and attachment support.
@@ -260,7 +329,7 @@ def _prepare_gmail_message(
attachments: Optional list of attachments. Each can have 'path' (file path) OR 'content' (base64) + 'filename'
Returns:
- Tuple of (raw_message, thread_id) where raw_message is base64 encoded
+ Tuple of (raw_message, thread_id, attached_count) where raw_message is base64 encoded
"""
# Handle reply subject formatting
reply_subject = subject
@@ -273,6 +342,7 @@ def _prepare_gmail_message(
raise ValueError("body_format must be either 'plain' or 'html'.")
# Use multipart if attachments are provided
+ attached_count = 0
if attachments:
message = MIMEMultipart()
message.attach(MIMEText(body, normalized_format))
@@ -344,6 +414,7 @@ def _prepare_gmail_message(
)
message.attach(part)
+ attached_count += 1
logger.info(f"Attached file: {filename} ({len(file_data)} bytes)")
except Exception as e:
logger.error(f"Failed to attach {filename or file_path}: {e}")
@@ -382,7 +453,7 @@ def _prepare_gmail_message(
# Encode message
raw_message = base64.urlsafe_b64encode(message.as_bytes()).decode()
- return raw_message, thread_id
+ return raw_message, thread_id, attached_count
def _generate_gmail_web_url(item_id: str, account_index: int = 0) -> str:
@@ -1177,7 +1248,7 @@ async def send_gmail_message(
# Prepare the email message
# Use from_email (Send As alias) if provided, otherwise default to authenticated user
sender_email = from_email or user_google_email
- raw_message, thread_id_final = _prepare_gmail_message(
+ raw_message, thread_id_final, attached_count = _prepare_gmail_message(
subject=subject,
body=body,
to=to,
@@ -1192,6 +1263,12 @@ async def send_gmail_message(
attachments=attachments if attachments else None,
)
+ requested_attachment_count = len(attachments or [])
+ if requested_attachment_count > 0 and attached_count == 0:
+ raise UserInputError(
+ "No valid attachments were added. Verify each attachment path/content and retry."
+ )
+
send_body = {"raw": raw_message}
# Associate with thread if provided
@@ -1204,8 +1281,11 @@ async def send_gmail_message(
)
message_id = sent_message.get("id")
- if attachments:
- return f"Email sent with {len(attachments)} attachment(s)! Message ID: {message_id}"
+ if requested_attachment_count > 0:
+ attachment_info = _format_attachment_result(
+ attached_count, requested_attachment_count
+ )
+ return f"Email sent{attachment_info}! Message ID: {message_id}"
return f"Email sent! Message ID: {message_id}"
@@ -1271,6 +1351,12 @@ async def draft_gmail_message(
description="Optional list of attachments. Each can have: 'path' (file path, auto-encodes), OR 'content' (standard base64, not urlsafe) + 'filename'. Optional 'mime_type' (auto-detected from path if not provided).",
),
] = None,
+ include_signature: Annotated[
+ bool,
+ Field(
+ description="Whether to append the Gmail signature from Settings > Signature when available. Defaults to true.",
+ ),
+ ] = True,
) -> str:
"""
Creates a draft email in the user's Gmail account. Supports both new drafts and reply drafts with optional attachments.
@@ -1300,6 +1386,8 @@ async def draft_gmail_message(
- 'content' (required): Standard base64-encoded file content (not urlsafe)
- 'filename' (required): Name of the file
- 'mime_type' (optional): MIME type (defaults to 'application/octet-stream')
+ include_signature (bool): Whether to append Gmail signature HTML from send-as settings.
+ If unavailable (e.g., missing gmail.settings.basic scope), the draft is still created without signature.
Returns:
str: Confirmation message with the created draft's ID.
@@ -1363,9 +1451,16 @@ async def draft_gmail_message(
# Prepare the email message
# Use from_email (Send As alias) if provided, otherwise default to authenticated user
sender_email = from_email or user_google_email
- raw_message, thread_id_final = _prepare_gmail_message(
+ draft_body = body
+ if include_signature:
+ signature_html = await _get_send_as_signature_html(
+ service, from_email=sender_email
+ )
+ draft_body = _append_signature_to_body(draft_body, body_format, signature_html)
+
+ raw_message, thread_id_final, attached_count = _prepare_gmail_message(
subject=subject,
- body=body,
+ body=draft_body,
body_format=body_format,
to=to,
cc=cc,
@@ -1378,6 +1473,12 @@ async def draft_gmail_message(
attachments=attachments,
)
+ requested_attachment_count = len(attachments or [])
+ if requested_attachment_count > 0 and attached_count == 0:
+ raise UserInputError(
+ "No valid attachments were added. Verify each attachment path/content and retry."
+ )
+
# Create a draft instead of sending
draft_body = {"message": {"raw": raw_message}}
@@ -1390,7 +1491,9 @@ async def draft_gmail_message(
service.users().drafts().create(userId="me", body=draft_body).execute
)
draft_id = created_draft.get("id")
- attachment_info = f" with {len(attachments)} attachment(s)" if attachments else ""
+ attachment_info = _format_attachment_result(
+ attached_count, requested_attachment_count
+ )
return f"Draft created{attachment_info}! Draft ID: {draft_id}"
@@ -1822,88 +1925,72 @@ async def list_gmail_filters(service, user_google_email: str) -> str:
@server.tool()
-@handle_http_errors("create_gmail_filter", service_type="gmail")
+@handle_http_errors("manage_gmail_filter", service_type="gmail")
@require_google_service("gmail", "gmail_settings_basic")
-async def create_gmail_filter(
+async def manage_gmail_filter(
service,
user_google_email: str,
- criteria: Annotated[
- Dict[str, Any],
- Field(
- description="Filter criteria object as defined in the Gmail API.",
- ),
- ],
- action: Annotated[
- Dict[str, Any],
- Field(
- description="Filter action object as defined in the Gmail API.",
- ),
- ],
+ action: str,
+ criteria: Optional[Dict[str, Any]] = None,
+ filter_action: Optional[Dict[str, Any]] = None,
+ filter_id: Optional[str] = None,
) -> str:
"""
- Creates a Gmail filter using the users.settings.filters API.
+ Manages Gmail filters. Supports creating and deleting filters.
Args:
user_google_email (str): The user's Google email address. Required.
- criteria (Dict[str, Any]): Criteria for matching messages.
- action (Dict[str, Any]): Actions to apply to matched messages.
+ action (str): Action to perform - "create" or "delete".
+ criteria (Optional[Dict[str, Any]]): Filter criteria object (required for create).
+ filter_action (Optional[Dict[str, Any]]): Filter action object (required for create). Named 'filter_action' to avoid shadowing the 'action' parameter.
+ filter_id (Optional[str]): ID of the filter to delete (required for delete).
Returns:
- str: Confirmation message with the created filter ID.
+ str: Confirmation message with filter details.
"""
- logger.info("[create_gmail_filter] Invoked")
-
- filter_body = {"criteria": criteria, "action": action}
-
- created_filter = await asyncio.to_thread(
- service.users()
- .settings()
- .filters()
- .create(userId="me", body=filter_body)
- .execute
- )
-
- filter_id = created_filter.get("id", "(unknown)")
- return f"Filter created successfully!\nFilter ID: {filter_id}"
-
-
-@server.tool()
-@handle_http_errors("delete_gmail_filter", service_type="gmail")
-@require_google_service("gmail", "gmail_settings_basic")
-async def delete_gmail_filter(
- service,
- user_google_email: str,
- filter_id: str = Field(..., description="ID of the filter to delete."),
-) -> str:
- """
- Deletes a Gmail filter by ID.
-
- Args:
- user_google_email (str): The user's Google email address. Required.
- filter_id (str): The ID of the filter to delete.
-
- Returns:
- str: Confirmation message for the deletion.
- """
- logger.info(f"[delete_gmail_filter] Invoked. Filter ID: '{filter_id}'")
-
- filter_details = await asyncio.to_thread(
- service.users().settings().filters().get(userId="me", id=filter_id).execute
- )
-
- await asyncio.to_thread(
- service.users().settings().filters().delete(userId="me", id=filter_id).execute
- )
-
- criteria = filter_details.get("criteria", {})
- action = filter_details.get("action", {})
-
- return (
- "Filter deleted successfully!\n"
- f"Filter ID: {filter_id}\n"
- f"Criteria: {criteria or '(none)'}\n"
- f"Action: {action or '(none)'}"
- )
+ action_lower = action.lower().strip()
+ if action_lower == "create":
+ if not criteria or not filter_action:
+ raise ValueError(
+ "criteria and filter_action are required for create action"
+ )
+ logger.info("[manage_gmail_filter] Creating filter")
+ filter_body = {"criteria": criteria, "action": filter_action}
+ created_filter = await asyncio.to_thread(
+ service.users()
+ .settings()
+ .filters()
+ .create(userId="me", body=filter_body)
+ .execute
+ )
+ fid = created_filter.get("id", "(unknown)")
+ return f"Filter created successfully!\nFilter ID: {fid}"
+ elif action_lower == "delete":
+ if not filter_id:
+ raise ValueError("filter_id is required for delete action")
+ logger.info(f"[manage_gmail_filter] Deleting filter {filter_id}")
+ filter_details = await asyncio.to_thread(
+ service.users().settings().filters().get(userId="me", id=filter_id).execute
+ )
+ await asyncio.to_thread(
+ service.users()
+ .settings()
+ .filters()
+ .delete(userId="me", id=filter_id)
+ .execute
+ )
+ criteria_info = filter_details.get("criteria", {})
+ action_info = filter_details.get("action", {})
+ return (
+ "Filter deleted successfully!\n"
+ f"Filter ID: {filter_id}\n"
+ f"Criteria: {criteria_info or '(none)'}\n"
+ f"Action: {action_info or '(none)'}"
+ )
+ else:
+ raise ValueError(
+ f"Invalid action '{action_lower}'. Must be 'create' or 'delete'."
+ )
@server.tool()
diff --git a/gsearch/search_tools.py b/gsearch/search_tools.py
index 6afb0fd..3e4694a 100644
--- a/gsearch/search_tools.py
+++ b/gsearch/search_tools.py
@@ -33,6 +33,7 @@ async def search_custom(
file_type: Optional[str] = None,
language: Optional[str] = None,
country: Optional[str] = None,
+ sites: Optional[List[str]] = None,
) -> str:
"""
Performs a search using Google Custom Search JSON API.
@@ -50,6 +51,7 @@ async def search_custom(
file_type (Optional[str]): Filter by file type (e.g., "pdf", "doc").
language (Optional[str]): Language code for results (e.g., "lang_en").
country (Optional[str]): Country code for results (e.g., "countryUS").
+ sites (Optional[List[str]]): List of sites/domains to restrict search to (e.g., ["example.com", "docs.example.com"]). When provided, results are limited to these sites.
Returns:
str: Formatted search results including title, link, and snippet for each result.
@@ -71,6 +73,12 @@ async def search_custom(
f"[search_custom] Invoked. Email: '{user_google_email}', Query: '{q}', CX: '{cx}'"
)
+ # Apply site restriction if sites are provided
+ if sites:
+ site_query = " OR ".join([f"site:{site}" for site in sites])
+ q = f"{q} ({site_query})"
+ logger.info(f"[search_custom] Applied site restriction: {sites}")
+
# Build the request parameters
params = {
"key": api_key,
@@ -224,50 +232,3 @@ async def get_search_engine_info(service, user_google_email: str) -> str:
logger.info(f"Search engine info retrieved successfully for {user_google_email}")
return confirmation_message
-
-
-@server.tool()
-@handle_http_errors(
- "search_custom_siterestrict", is_read_only=True, service_type="customsearch"
-)
-@require_google_service("customsearch", "customsearch")
-async def search_custom_siterestrict(
- service,
- user_google_email: str,
- q: str,
- sites: List[str],
- num: int = 10,
- start: int = 1,
- safe: Literal["active", "moderate", "off"] = "off",
-) -> str:
- """
- Performs a search restricted to specific sites using Google Custom Search.
-
- Args:
- user_google_email (str): The user's Google email address. Required.
- q (str): The search query. Required.
- sites (List[str]): List of sites/domains to search within.
- num (int): Number of results to return (1-10). Defaults to 10.
- start (int): The index of the first result to return (1-based). Defaults to 1.
- safe (Literal["active", "moderate", "off"]): Safe search level. Defaults to "off".
-
- Returns:
- str: Formatted search results from the specified sites.
- """
- logger.info(
- f"[search_custom_siterestrict] Invoked. Email: '{user_google_email}', Query: '{q}', Sites: {sites}"
- )
-
- # Build site restriction query
- site_query = " OR ".join([f"site:{site}" for site in sites])
- full_query = f"{q} ({site_query})"
-
- # Use the main search function with the modified query
- return await search_custom(
- service=service,
- user_google_email=user_google_email,
- q=full_query,
- num=num,
- start=start,
- safe=safe,
- )
diff --git a/gsheets/sheets_tools.py b/gsheets/sheets_tools.py
index ef9501d..04676f9 100644
--- a/gsheets/sheets_tools.py
+++ b/gsheets/sheets_tools.py
@@ -729,405 +729,401 @@ async def format_sheet_range(
@server.tool()
-@handle_http_errors("add_conditional_formatting", service_type="sheets")
+@handle_http_errors("manage_conditional_formatting", service_type="sheets")
@require_google_service("sheets", "sheets_write")
-async def add_conditional_formatting(
+async def manage_conditional_formatting(
service,
user_google_email: str,
spreadsheet_id: str,
- range_name: str,
- condition_type: str,
- condition_values: Optional[Union[str, List[Union[str, int, float]]]] = None,
- background_color: Optional[str] = None,
- text_color: Optional[str] = None,
- rule_index: Optional[int] = None,
- gradient_points: Optional[Union[str, List[dict]]] = None,
-) -> str:
- """
- Adds a conditional formatting rule to a range.
-
- Args:
- user_google_email (str): The user's Google email address. Required.
- spreadsheet_id (str): The ID of the spreadsheet. Required.
- range_name (str): A1-style range (optionally with sheet name). Required.
- condition_type (str): Sheets condition type (e.g., NUMBER_GREATER, TEXT_CONTAINS, DATE_BEFORE, CUSTOM_FORMULA).
- condition_values (Optional[Union[str, List[Union[str, int, float]]]]): Values for the condition; accepts a list or a JSON string representing a list. Depends on condition_type.
- background_color (Optional[str]): Hex background color to apply when condition matches.
- text_color (Optional[str]): Hex text color to apply when condition matches.
- rule_index (Optional[int]): Optional position to insert the rule (0-based) within the sheet's rules.
- gradient_points (Optional[Union[str, List[dict]]]): List (or JSON list) of gradient points for a color scale. If provided, a gradient rule is created and boolean parameters are ignored.
-
- Returns:
- str: Confirmation of the added rule.
- """
- logger.info(
- "[add_conditional_formatting] Invoked. Email: '%s', Spreadsheet: %s, Range: %s, Type: %s, Values: %s",
- user_google_email,
- spreadsheet_id,
- range_name,
- condition_type,
- condition_values,
- )
-
- if rule_index is not None and (not isinstance(rule_index, int) or rule_index < 0):
- raise UserInputError("rule_index must be a non-negative integer when provided.")
-
- condition_values_list = _parse_condition_values(condition_values)
- gradient_points_list = _parse_gradient_points(gradient_points)
-
- sheets, sheet_titles = await _fetch_sheets_with_rules(service, spreadsheet_id)
- grid_range = _parse_a1_range(range_name, sheets)
-
- target_sheet = None
- for sheet in sheets:
- if sheet.get("properties", {}).get("sheetId") == grid_range.get("sheetId"):
- target_sheet = sheet
- break
- if target_sheet is None:
- raise UserInputError(
- "Target sheet not found while adding conditional formatting."
- )
-
- current_rules = target_sheet.get("conditionalFormats", []) or []
-
- insert_at = rule_index if rule_index is not None else len(current_rules)
- if insert_at > len(current_rules):
- raise UserInputError(
- f"rule_index {insert_at} is out of range for sheet '{target_sheet.get('properties', {}).get('title', 'Unknown')}' "
- f"(current count: {len(current_rules)})."
- )
-
- if gradient_points_list:
- new_rule = _build_gradient_rule([grid_range], gradient_points_list)
- rule_desc = "gradient"
- values_desc = ""
- applied_parts = [f"gradient points {len(gradient_points_list)}"]
- else:
- rule, cond_type_normalized = _build_boolean_rule(
- [grid_range],
- condition_type,
- condition_values_list,
- background_color,
- text_color,
- )
- new_rule = rule
- rule_desc = cond_type_normalized
- values_desc = ""
- if condition_values_list:
- values_desc = f" with values {condition_values_list}"
- applied_parts = []
- if background_color:
- applied_parts.append(f"background {background_color}")
- if text_color:
- applied_parts.append(f"text {text_color}")
-
- new_rules_state = copy.deepcopy(current_rules)
- new_rules_state.insert(insert_at, new_rule)
-
- add_rule_request = {"rule": new_rule}
- if rule_index is not None:
- add_rule_request["index"] = rule_index
-
- request_body = {"requests": [{"addConditionalFormatRule": add_rule_request}]}
-
- await asyncio.to_thread(
- service.spreadsheets()
- .batchUpdate(spreadsheetId=spreadsheet_id, body=request_body)
- .execute
- )
-
- format_desc = ", ".join(applied_parts) if applied_parts else "format applied"
-
- sheet_title = target_sheet.get("properties", {}).get("title", "Unknown")
- state_text = _format_conditional_rules_section(
- sheet_title, new_rules_state, sheet_titles, indent=""
- )
-
- return "\n".join(
- [
- f"Added conditional format on '{range_name}' in spreadsheet {spreadsheet_id} "
- f"for {user_google_email}: {rule_desc}{values_desc}; format: {format_desc}.",
- state_text,
- ]
- )
-
-
-@server.tool()
-@handle_http_errors("update_conditional_formatting", service_type="sheets")
-@require_google_service("sheets", "sheets_write")
-async def update_conditional_formatting(
- service,
- user_google_email: str,
- spreadsheet_id: str,
- rule_index: int,
+ action: str,
range_name: Optional[str] = None,
condition_type: Optional[str] = None,
condition_values: Optional[Union[str, List[Union[str, int, float]]]] = None,
background_color: Optional[str] = None,
text_color: Optional[str] = None,
- sheet_name: Optional[str] = None,
+ rule_index: Optional[int] = None,
gradient_points: Optional[Union[str, List[dict]]] = None,
+ sheet_name: Optional[str] = None,
) -> str:
"""
- Updates an existing conditional formatting rule by index on a sheet.
+ Manages conditional formatting rules on a Google Sheet. Supports adding,
+ updating, and deleting conditional formatting rules via a single tool.
Args:
user_google_email (str): The user's Google email address. Required.
spreadsheet_id (str): The ID of the spreadsheet. Required.
- range_name (Optional[str]): A1-style range to apply the updated rule (optionally with sheet name). If omitted, existing ranges are preserved.
- rule_index (int): Index of the rule to update (0-based).
- condition_type (Optional[str]): Sheets condition type. If omitted, the existing rule's type is preserved.
- condition_values (Optional[Union[str, List[Union[str, int, float]]]]): Values for the condition.
- background_color (Optional[str]): Hex background color when condition matches.
- text_color (Optional[str]): Hex text color when condition matches.
- sheet_name (Optional[str]): Sheet name to locate the rule when range_name is omitted. Defaults to first sheet.
- gradient_points (Optional[Union[str, List[dict]]]): If provided, updates the rule to a gradient color scale using these points.
+ action (str): The operation to perform. Must be one of "add", "update",
+ or "delete".
+ range_name (Optional[str]): A1-style range (optionally with sheet name).
+ Required for "add". Optional for "update" (preserves existing ranges
+ if omitted). Not used for "delete".
+ condition_type (Optional[str]): Sheets condition type (e.g., NUMBER_GREATER,
+ TEXT_CONTAINS, DATE_BEFORE, CUSTOM_FORMULA). Required for "add".
+ Optional for "update" (preserves existing type if omitted).
+ condition_values (Optional[Union[str, List[Union[str, int, float]]]]): Values
+ for the condition; accepts a list or a JSON string representing a list.
+ Depends on condition_type. Used by "add" and "update".
+ background_color (Optional[str]): Hex background color to apply when
+ condition matches. Used by "add" and "update".
+ text_color (Optional[str]): Hex text color to apply when condition matches.
+ Used by "add" and "update".
+ rule_index (Optional[int]): 0-based index of the rule. For "add", optionally
+ specifies insertion position. Required for "update" and "delete".
+ gradient_points (Optional[Union[str, List[dict]]]): List (or JSON list) of
+ gradient points for a color scale. If provided, a gradient rule is created
+ and boolean parameters are ignored. Used by "add" and "update".
+ sheet_name (Optional[str]): Sheet name to locate the rule when range_name is
+ omitted. Defaults to the first sheet. Used by "update" and "delete".
Returns:
- str: Confirmation of the updated rule and the current rule state.
+ str: Confirmation of the operation and the current rule state.
"""
+ allowed_actions = {"add", "update", "delete"}
+ action_normalized = action.strip().lower()
+ if action_normalized not in allowed_actions:
+ raise UserInputError(
+ f"action must be one of {sorted(allowed_actions)}, got '{action}'."
+ )
+
logger.info(
- "[update_conditional_formatting] Invoked. Email: '%s', Spreadsheet: %s, Range: %s, Rule Index: %s",
+ "[manage_conditional_formatting] Invoked. Action: '%s', Email: '%s', Spreadsheet: %s",
+ action_normalized,
user_google_email,
spreadsheet_id,
- range_name,
- rule_index,
)
- if not isinstance(rule_index, int) or rule_index < 0:
- raise UserInputError("rule_index must be a non-negative integer.")
+ if action_normalized == "add":
+ if not range_name:
+ raise UserInputError("range_name is required for action 'add'.")
+ if not condition_type and not gradient_points:
+ raise UserInputError(
+ "condition_type (or gradient_points) is required for action 'add'."
+ )
- condition_values_list = _parse_condition_values(condition_values)
- gradient_points_list = _parse_gradient_points(gradient_points)
+ if rule_index is not None and (
+ not isinstance(rule_index, int) or rule_index < 0
+ ):
+ raise UserInputError(
+ "rule_index must be a non-negative integer when provided."
+ )
- sheets, sheet_titles = await _fetch_sheets_with_rules(service, spreadsheet_id)
+ gradient_points_list = _parse_gradient_points(gradient_points)
+ condition_values_list = (
+ None if gradient_points_list else _parse_condition_values(condition_values)
+ )
- target_sheet = None
- grid_range = None
- if range_name:
+ sheets, sheet_titles = await _fetch_sheets_with_rules(service, spreadsheet_id)
grid_range = _parse_a1_range(range_name, sheets)
+
+ target_sheet = None
for sheet in sheets:
if sheet.get("properties", {}).get("sheetId") == grid_range.get("sheetId"):
target_sheet = sheet
break
- else:
+ if target_sheet is None:
+ raise UserInputError(
+ "Target sheet not found while adding conditional formatting."
+ )
+
+ current_rules = target_sheet.get("conditionalFormats", []) or []
+
+ insert_at = rule_index if rule_index is not None else len(current_rules)
+ if insert_at > len(current_rules):
+ raise UserInputError(
+ f"rule_index {insert_at} is out of range for sheet "
+ f"'{target_sheet.get('properties', {}).get('title', 'Unknown')}' "
+ f"(current count: {len(current_rules)})."
+ )
+
+ if gradient_points_list:
+ new_rule = _build_gradient_rule([grid_range], gradient_points_list)
+ rule_desc = "gradient"
+ values_desc = ""
+ applied_parts = [f"gradient points {len(gradient_points_list)}"]
+ else:
+ rule, cond_type_normalized = _build_boolean_rule(
+ [grid_range],
+ condition_type,
+ condition_values_list,
+ background_color,
+ text_color,
+ )
+ new_rule = rule
+ rule_desc = cond_type_normalized
+ values_desc = ""
+ if condition_values_list:
+ values_desc = f" with values {condition_values_list}"
+ applied_parts = []
+ if background_color:
+ applied_parts.append(f"background {background_color}")
+ if text_color:
+ applied_parts.append(f"text {text_color}")
+
+ new_rules_state = copy.deepcopy(current_rules)
+ new_rules_state.insert(insert_at, new_rule)
+
+ add_rule_request = {"rule": new_rule}
+ if rule_index is not None:
+ add_rule_request["index"] = rule_index
+
+ request_body = {"requests": [{"addConditionalFormatRule": add_rule_request}]}
+
+ await asyncio.to_thread(
+ service.spreadsheets()
+ .batchUpdate(spreadsheetId=spreadsheet_id, body=request_body)
+ .execute
+ )
+
+ format_desc = ", ".join(applied_parts) if applied_parts else "format applied"
+
+ sheet_title = target_sheet.get("properties", {}).get("title", "Unknown")
+ state_text = _format_conditional_rules_section(
+ sheet_title, new_rules_state, sheet_titles, indent=""
+ )
+
+ return "\n".join(
+ [
+ f"Added conditional format on '{range_name}' in spreadsheet "
+ f"{spreadsheet_id} for {user_google_email}: "
+ f"{rule_desc}{values_desc}; format: {format_desc}.",
+ state_text,
+ ]
+ )
+
+ elif action_normalized == "update":
+ if rule_index is None:
+ raise UserInputError("rule_index is required for action 'update'.")
+ if not isinstance(rule_index, int) or rule_index < 0:
+ raise UserInputError("rule_index must be a non-negative integer.")
+
+ gradient_points_list = _parse_gradient_points(gradient_points)
+ condition_values_list = (
+ None
+ if gradient_points_list is not None
+ else _parse_condition_values(condition_values)
+ )
+
+ sheets, sheet_titles = await _fetch_sheets_with_rules(service, spreadsheet_id)
+
+ target_sheet = None
+ grid_range = None
+ if range_name:
+ grid_range = _parse_a1_range(range_name, sheets)
+ for sheet in sheets:
+ if sheet.get("properties", {}).get("sheetId") == grid_range.get(
+ "sheetId"
+ ):
+ target_sheet = sheet
+ break
+ else:
+ target_sheet = _select_sheet(sheets, sheet_name)
+
+ if target_sheet is None:
+ raise UserInputError(
+ "Target sheet not found while updating conditional formatting."
+ )
+
+ sheet_props = target_sheet.get("properties", {})
+ sheet_id = sheet_props.get("sheetId")
+ sheet_title = sheet_props.get("title", f"Sheet {sheet_id}")
+
+ rules = target_sheet.get("conditionalFormats", []) or []
+ if rule_index >= len(rules):
+ raise UserInputError(
+ f"rule_index {rule_index} is out of range for sheet "
+ f"'{sheet_title}' (current count: {len(rules)})."
+ )
+
+ existing_rule = rules[rule_index]
+ ranges_to_use = existing_rule.get("ranges", [])
+ if range_name:
+ ranges_to_use = [grid_range]
+ if not ranges_to_use:
+ ranges_to_use = [{"sheetId": sheet_id}]
+
+ new_rule = None
+ rule_desc = ""
+ values_desc = ""
+ format_desc = ""
+
+ if gradient_points_list is not None:
+ new_rule = _build_gradient_rule(ranges_to_use, gradient_points_list)
+ rule_desc = "gradient"
+ format_desc = f"gradient points {len(gradient_points_list)}"
+ elif "gradientRule" in existing_rule:
+ if any(
+ [
+ background_color,
+ text_color,
+ condition_type,
+ condition_values_list,
+ ]
+ ):
+ raise UserInputError(
+ "Existing rule is a gradient rule. Provide gradient_points "
+ "to update it, or omit formatting/condition parameters to "
+ "keep it unchanged."
+ )
+ new_rule = {
+ "ranges": ranges_to_use,
+ "gradientRule": existing_rule.get("gradientRule", {}),
+ }
+ rule_desc = "gradient"
+ format_desc = "gradient (unchanged)"
+ else:
+ existing_boolean = existing_rule.get("booleanRule", {})
+ existing_condition = existing_boolean.get("condition", {})
+ existing_format = copy.deepcopy(existing_boolean.get("format", {}))
+
+ cond_type = (condition_type or existing_condition.get("type", "")).upper()
+ if not cond_type:
+ raise UserInputError("condition_type is required for boolean rules.")
+ if cond_type not in CONDITION_TYPES:
+ raise UserInputError(
+ f"condition_type must be one of {sorted(CONDITION_TYPES)}."
+ )
+
+ if condition_values_list is not None:
+ cond_values = [
+ {"userEnteredValue": str(val)} for val in condition_values_list
+ ]
+ else:
+ cond_values = existing_condition.get("values")
+
+ new_format = copy.deepcopy(existing_format) if existing_format else {}
+ if background_color is not None:
+ bg_color_parsed = _parse_hex_color(background_color)
+ if bg_color_parsed:
+ new_format["backgroundColor"] = bg_color_parsed
+ elif "backgroundColor" in new_format:
+ del new_format["backgroundColor"]
+ if text_color is not None:
+ text_color_parsed = _parse_hex_color(text_color)
+ text_format = copy.deepcopy(new_format.get("textFormat", {}))
+ if text_color_parsed:
+ text_format["foregroundColor"] = text_color_parsed
+ elif "foregroundColor" in text_format:
+ del text_format["foregroundColor"]
+ if text_format:
+ new_format["textFormat"] = text_format
+ elif "textFormat" in new_format:
+ del new_format["textFormat"]
+
+ if not new_format:
+ raise UserInputError(
+ "At least one format option must remain on the rule."
+ )
+
+ new_rule = {
+ "ranges": ranges_to_use,
+ "booleanRule": {
+ "condition": {"type": cond_type},
+ "format": new_format,
+ },
+ }
+ if cond_values:
+ new_rule["booleanRule"]["condition"]["values"] = cond_values
+
+ rule_desc = cond_type
+ if condition_values_list:
+ values_desc = f" with values {condition_values_list}"
+ format_parts = []
+ if "backgroundColor" in new_format:
+ format_parts.append("background updated")
+ if "textFormat" in new_format and new_format["textFormat"].get(
+ "foregroundColor"
+ ):
+ format_parts.append("text color updated")
+ format_desc = (
+ ", ".join(format_parts) if format_parts else "format preserved"
+ )
+
+ new_rules_state = copy.deepcopy(rules)
+ new_rules_state[rule_index] = new_rule
+
+ request_body = {
+ "requests": [
+ {
+ "updateConditionalFormatRule": {
+ "index": rule_index,
+ "sheetId": sheet_id,
+ "rule": new_rule,
+ }
+ }
+ ]
+ }
+
+ await asyncio.to_thread(
+ service.spreadsheets()
+ .batchUpdate(spreadsheetId=spreadsheet_id, body=request_body)
+ .execute
+ )
+
+ state_text = _format_conditional_rules_section(
+ sheet_title, new_rules_state, sheet_titles, indent=""
+ )
+
+ return "\n".join(
+ [
+ f"Updated conditional format at index {rule_index} on sheet "
+ f"'{sheet_title}' in spreadsheet {spreadsheet_id} "
+ f"for {user_google_email}: "
+ f"{rule_desc}{values_desc}; format: {format_desc}.",
+ state_text,
+ ]
+ )
+
+ else: # action_normalized == "delete"
+ if rule_index is None:
+ raise UserInputError("rule_index is required for action 'delete'.")
+ if not isinstance(rule_index, int) or rule_index < 0:
+ raise UserInputError("rule_index must be a non-negative integer.")
+
+ sheets, sheet_titles = await _fetch_sheets_with_rules(service, spreadsheet_id)
target_sheet = _select_sheet(sheets, sheet_name)
- if target_sheet is None:
- raise UserInputError(
- "Target sheet not found while updating conditional formatting."
- )
-
- sheet_props = target_sheet.get("properties", {})
- sheet_id = sheet_props.get("sheetId")
- sheet_title = sheet_props.get("title", f"Sheet {sheet_id}")
-
- rules = target_sheet.get("conditionalFormats", []) or []
- if rule_index >= len(rules):
- raise UserInputError(
- f"rule_index {rule_index} is out of range for sheet '{sheet_title}' (current count: {len(rules)})."
- )
-
- existing_rule = rules[rule_index]
- ranges_to_use = existing_rule.get("ranges", [])
- if range_name:
- ranges_to_use = [grid_range]
- if not ranges_to_use:
- ranges_to_use = [{"sheetId": sheet_id}]
-
- new_rule = None
- rule_desc = ""
- values_desc = ""
- format_desc = ""
-
- if gradient_points_list is not None:
- new_rule = _build_gradient_rule(ranges_to_use, gradient_points_list)
- rule_desc = "gradient"
- format_desc = f"gradient points {len(gradient_points_list)}"
- elif "gradientRule" in existing_rule:
- if any([background_color, text_color, condition_type, condition_values_list]):
+ sheet_props = target_sheet.get("properties", {})
+ sheet_id = sheet_props.get("sheetId")
+ target_sheet_name = sheet_props.get("title", f"Sheet {sheet_id}")
+ rules = target_sheet.get("conditionalFormats", []) or []
+ if rule_index >= len(rules):
raise UserInputError(
- "Existing rule is a gradient rule. Provide gradient_points to update it, or omit formatting/condition parameters to keep it unchanged."
- )
- new_rule = {
- "ranges": ranges_to_use,
- "gradientRule": existing_rule.get("gradientRule", {}),
- }
- rule_desc = "gradient"
- format_desc = "gradient (unchanged)"
- else:
- existing_boolean = existing_rule.get("booleanRule", {})
- existing_condition = existing_boolean.get("condition", {})
- existing_format = copy.deepcopy(existing_boolean.get("format", {}))
-
- cond_type = (condition_type or existing_condition.get("type", "")).upper()
- if not cond_type:
- raise UserInputError("condition_type is required for boolean rules.")
- if cond_type not in CONDITION_TYPES:
- raise UserInputError(
- f"condition_type must be one of {sorted(CONDITION_TYPES)}."
+ f"rule_index {rule_index} is out of range for sheet "
+ f"'{target_sheet_name}' (current count: {len(rules)})."
)
- if condition_values_list is not None:
- cond_values = [
- {"userEnteredValue": str(val)} for val in condition_values_list
+ new_rules_state = copy.deepcopy(rules)
+ del new_rules_state[rule_index]
+
+ request_body = {
+ "requests": [
+ {
+ "deleteConditionalFormatRule": {
+ "index": rule_index,
+ "sheetId": sheet_id,
+ }
+ }
]
- else:
- cond_values = existing_condition.get("values")
-
- new_format = copy.deepcopy(existing_format) if existing_format else {}
- if background_color is not None:
- bg_color_parsed = _parse_hex_color(background_color)
- if bg_color_parsed:
- new_format["backgroundColor"] = bg_color_parsed
- elif "backgroundColor" in new_format:
- del new_format["backgroundColor"]
- if text_color is not None:
- text_color_parsed = _parse_hex_color(text_color)
- text_format = copy.deepcopy(new_format.get("textFormat", {}))
- if text_color_parsed:
- text_format["foregroundColor"] = text_color_parsed
- elif "foregroundColor" in text_format:
- del text_format["foregroundColor"]
- if text_format:
- new_format["textFormat"] = text_format
- elif "textFormat" in new_format:
- del new_format["textFormat"]
-
- if not new_format:
- raise UserInputError("At least one format option must remain on the rule.")
-
- new_rule = {
- "ranges": ranges_to_use,
- "booleanRule": {
- "condition": {"type": cond_type},
- "format": new_format,
- },
}
- if cond_values:
- new_rule["booleanRule"]["condition"]["values"] = cond_values
- rule_desc = cond_type
- if condition_values_list:
- values_desc = f" with values {condition_values_list}"
- format_parts = []
- if "backgroundColor" in new_format:
- format_parts.append("background updated")
- if "textFormat" in new_format and new_format["textFormat"].get(
- "foregroundColor"
- ):
- format_parts.append("text color updated")
- format_desc = ", ".join(format_parts) if format_parts else "format preserved"
-
- new_rules_state = copy.deepcopy(rules)
- new_rules_state[rule_index] = new_rule
-
- request_body = {
- "requests": [
- {
- "updateConditionalFormatRule": {
- "index": rule_index,
- "sheetId": sheet_id,
- "rule": new_rule,
- }
- }
- ]
- }
-
- await asyncio.to_thread(
- service.spreadsheets()
- .batchUpdate(spreadsheetId=spreadsheet_id, body=request_body)
- .execute
- )
-
- state_text = _format_conditional_rules_section(
- sheet_title, new_rules_state, sheet_titles, indent=""
- )
-
- return "\n".join(
- [
- f"Updated conditional format at index {rule_index} on sheet '{sheet_title}' in spreadsheet {spreadsheet_id} "
- f"for {user_google_email}: {rule_desc}{values_desc}; format: {format_desc}.",
- state_text,
- ]
- )
-
-
-@server.tool()
-@handle_http_errors("delete_conditional_formatting", service_type="sheets")
-@require_google_service("sheets", "sheets_write")
-async def delete_conditional_formatting(
- service,
- user_google_email: str,
- spreadsheet_id: str,
- rule_index: int,
- sheet_name: Optional[str] = None,
-) -> str:
- """
- Deletes an existing conditional formatting rule by index on a sheet.
-
- Args:
- user_google_email (str): The user's Google email address. Required.
- spreadsheet_id (str): The ID of the spreadsheet. Required.
- rule_index (int): Index of the rule to delete (0-based).
- sheet_name (Optional[str]): Name of the sheet that contains the rule. Defaults to the first sheet if not provided.
-
- Returns:
- str: Confirmation of the deletion and the current rule state.
- """
- logger.info(
- "[delete_conditional_formatting] Invoked. Email: '%s', Spreadsheet: %s, Sheet: %s, Rule Index: %s",
- user_google_email,
- spreadsheet_id,
- sheet_name,
- rule_index,
- )
-
- if not isinstance(rule_index, int) or rule_index < 0:
- raise UserInputError("rule_index must be a non-negative integer.")
-
- sheets, sheet_titles = await _fetch_sheets_with_rules(service, spreadsheet_id)
- target_sheet = _select_sheet(sheets, sheet_name)
-
- sheet_props = target_sheet.get("properties", {})
- sheet_id = sheet_props.get("sheetId")
- target_sheet_name = sheet_props.get("title", f"Sheet {sheet_id}")
- rules = target_sheet.get("conditionalFormats", []) or []
- if rule_index >= len(rules):
- raise UserInputError(
- f"rule_index {rule_index} is out of range for sheet '{target_sheet_name}' (current count: {len(rules)})."
+ await asyncio.to_thread(
+ service.spreadsheets()
+ .batchUpdate(spreadsheetId=spreadsheet_id, body=request_body)
+ .execute
)
- new_rules_state = copy.deepcopy(rules)
- del new_rules_state[rule_index]
+ state_text = _format_conditional_rules_section(
+ target_sheet_name, new_rules_state, sheet_titles, indent=""
+ )
- request_body = {
- "requests": [
- {
- "deleteConditionalFormatRule": {
- "index": rule_index,
- "sheetId": sheet_id,
- }
- }
- ]
- }
-
- await asyncio.to_thread(
- service.spreadsheets()
- .batchUpdate(spreadsheetId=spreadsheet_id, body=request_body)
- .execute
- )
-
- state_text = _format_conditional_rules_section(
- target_sheet_name, new_rules_state, sheet_titles, indent=""
- )
-
- return "\n".join(
- [
- f"Deleted conditional format at index {rule_index} on sheet '{target_sheet_name}' in spreadsheet {spreadsheet_id} for {user_google_email}.",
- state_text,
- ]
- )
+ return "\n".join(
+ [
+ f"Deleted conditional format at index {rule_index} on sheet "
+ f"'{target_sheet_name}' in spreadsheet {spreadsheet_id} "
+ f"for {user_google_email}.",
+ state_text,
+ ]
+ )
@server.tool()
@@ -1232,7 +1228,5 @@ async def create_sheet(
_comment_tools = create_comment_tools("spreadsheet", "spreadsheet_id")
# Extract and register the functions
-read_sheet_comments = _comment_tools["read_comments"]
-create_sheet_comment = _comment_tools["create_comment"]
-reply_to_sheet_comment = _comment_tools["reply_to_comment"]
-resolve_sheet_comment = _comment_tools["resolve_comment"]
+list_spreadsheet_comments = _comment_tools["list_comments"]
+manage_spreadsheet_comment = _comment_tools["manage_comment"]
diff --git a/gslides/slides_tools.py b/gslides/slides_tools.py
index 2c2c9be..02a5007 100644
--- a/gslides/slides_tools.py
+++ b/gslides/slides_tools.py
@@ -322,13 +322,9 @@ You can view or download the thumbnail using the provided URL."""
# Create comment management tools for slides
_comment_tools = create_comment_tools("presentation", "presentation_id")
-read_presentation_comments = _comment_tools["read_comments"]
-create_presentation_comment = _comment_tools["create_comment"]
-reply_to_presentation_comment = _comment_tools["reply_to_comment"]
-resolve_presentation_comment = _comment_tools["resolve_comment"]
+list_presentation_comments = _comment_tools["list_comments"]
+manage_presentation_comment = _comment_tools["manage_comment"]
# Aliases for backwards compatibility and intuitive naming
-read_slide_comments = read_presentation_comments
-create_slide_comment = create_presentation_comment
-reply_to_slide_comment = reply_to_presentation_comment
-resolve_slide_comment = resolve_presentation_comment
+list_slide_comments = list_presentation_comments
+manage_slide_comment = manage_presentation_comment
diff --git a/gtasks/tasks_tools.py b/gtasks/tasks_tools.py
index 1e5b7ab..cc6d6bf 100644
--- a/gtasks/tasks_tools.py
+++ b/gtasks/tasks_tools.py
@@ -15,7 +15,7 @@ from mcp import Resource
from auth.oauth_config import is_oauth21_enabled, is_external_oauth21_provider
from auth.service_decorator import require_google_service
from core.server import server
-from core.utils import handle_http_errors
+from core.utils import UserInputError, handle_http_errors
logger = logging.getLogger(__name__)
@@ -193,138 +193,155 @@ async def get_task_list(
raise Exception(message)
-@server.tool() # type: ignore
-@require_google_service("tasks", "tasks") # type: ignore
-@handle_http_errors("create_task_list", service_type="tasks") # type: ignore
-async def create_task_list(
+# --- Task list _impl functions ---
+
+
+async def _create_task_list_impl(
service: Resource, user_google_email: str, title: str
) -> str:
- """
- Create a new task list.
-
- Args:
- user_google_email (str): The user's Google email address. Required.
- title (str): The title of the new task list.
-
- Returns:
- str: Confirmation message with the new task list ID and details.
- """
+ """Implementation for creating a new task list."""
logger.info(
f"[create_task_list] Invoked. Email: '{user_google_email}', Title: '{title}'"
)
- try:
- body = {"title": title}
+ body = {"title": title}
- result = await asyncio.to_thread(service.tasklists().insert(body=body).execute)
+ result = await asyncio.to_thread(service.tasklists().insert(body=body).execute)
- response = f"""Task List Created for {user_google_email}:
+ response = f"""Task List Created for {user_google_email}:
- Title: {result["title"]}
- ID: {result["id"]}
- Created: {result.get("updated", "N/A")}
- Self Link: {result.get("selfLink", "N/A")}"""
- logger.info(
- f"Created task list '{title}' with ID {result['id']} for {user_google_email}"
- )
- return response
-
- except HttpError as error:
- message = _format_reauth_message(error, user_google_email)
- logger.error(message, exc_info=True)
- raise Exception(message)
- except Exception as e:
- message = f"Unexpected error: {e}."
- logger.exception(message)
- raise Exception(message)
+ logger.info(
+ f"Created task list '{title}' with ID {result['id']} for {user_google_email}"
+ )
+ return response
-@server.tool() # type: ignore
-@require_google_service("tasks", "tasks") # type: ignore
-@handle_http_errors("update_task_list", service_type="tasks") # type: ignore
-async def update_task_list(
+async def _update_task_list_impl(
service: Resource, user_google_email: str, task_list_id: str, title: str
) -> str:
- """
- Update an existing task list.
-
- Args:
- user_google_email (str): The user's Google email address. Required.
- task_list_id (str): The ID of the task list to update.
- title (str): The new title for the task list.
-
- Returns:
- str: Confirmation message with updated task list details.
- """
+ """Implementation for updating an existing task list."""
logger.info(
f"[update_task_list] Invoked. Email: '{user_google_email}', Task List ID: {task_list_id}, New Title: '{title}'"
)
- try:
- body = {"id": task_list_id, "title": title}
+ body = {"id": task_list_id, "title": title}
- result = await asyncio.to_thread(
- service.tasklists().update(tasklist=task_list_id, body=body).execute
- )
+ result = await asyncio.to_thread(
+ service.tasklists().update(tasklist=task_list_id, body=body).execute
+ )
- response = f"""Task List Updated for {user_google_email}:
+ response = f"""Task List Updated for {user_google_email}:
- Title: {result["title"]}
- ID: {result["id"]}
- Updated: {result.get("updated", "N/A")}"""
- logger.info(
- f"Updated task list {task_list_id} with new title '{title}' for {user_google_email}"
- )
- return response
-
- except HttpError as error:
- message = _format_reauth_message(error, user_google_email)
- logger.error(message, exc_info=True)
- raise Exception(message)
- except Exception as e:
- message = f"Unexpected error: {e}."
- logger.exception(message)
- raise Exception(message)
+ logger.info(
+ f"Updated task list {task_list_id} with new title '{title}' for {user_google_email}"
+ )
+ return response
-@server.tool() # type: ignore
-@require_google_service("tasks", "tasks") # type: ignore
-@handle_http_errors("delete_task_list", service_type="tasks") # type: ignore
-async def delete_task_list(
+async def _delete_task_list_impl(
service: Resource, user_google_email: str, task_list_id: str
) -> str:
- """
- Delete a task list. Note: This will also delete all tasks in the list.
-
- Args:
- user_google_email (str): The user's Google email address. Required.
- task_list_id (str): The ID of the task list to delete.
-
- Returns:
- str: Confirmation message.
- """
+ """Implementation for deleting a task list."""
logger.info(
f"[delete_task_list] Invoked. Email: '{user_google_email}', Task List ID: {task_list_id}"
)
- try:
- await asyncio.to_thread(
- service.tasklists().delete(tasklist=task_list_id).execute
+ await asyncio.to_thread(service.tasklists().delete(tasklist=task_list_id).execute)
+
+ response = f"Task list {task_list_id} has been deleted for {user_google_email}. All tasks in this list have also been deleted."
+
+ logger.info(f"Deleted task list {task_list_id} for {user_google_email}")
+ return response
+
+
+async def _clear_completed_tasks_impl(
+ service: Resource, user_google_email: str, task_list_id: str
+) -> str:
+ """Implementation for clearing completed tasks from a task list."""
+ logger.info(
+ f"[clear_completed_tasks] Invoked. Email: '{user_google_email}', Task List ID: {task_list_id}"
+ )
+
+ await asyncio.to_thread(service.tasks().clear(tasklist=task_list_id).execute)
+
+ response = f"All completed tasks have been cleared from task list {task_list_id} for {user_google_email}. The tasks are now hidden and won't appear in default task list views."
+
+ logger.info(
+ f"Cleared completed tasks from list {task_list_id} for {user_google_email}"
+ )
+ return response
+
+
+# --- Consolidated manage_task_list tool ---
+
+
+@server.tool() # type: ignore
+@require_google_service("tasks", "tasks") # type: ignore
+@handle_http_errors("manage_task_list", service_type="tasks") # type: ignore
+async def manage_task_list(
+ service: Resource,
+ user_google_email: str,
+ action: str,
+ task_list_id: Optional[str] = None,
+ title: Optional[str] = None,
+) -> str:
+ """
+ Manage task lists: create, update, delete, or clear completed tasks.
+
+ Args:
+ user_google_email (str): The user's Google email address. Required.
+ action (str): The action to perform. Must be one of: "create", "update", "delete", "clear_completed".
+ task_list_id (Optional[str]): The ID of the task list. Required for "update", "delete", and "clear_completed" actions.
+ title (Optional[str]): The title for the task list. Required for "create" and "update" actions.
+
+ Returns:
+ str: Result of the requested action.
+ """
+ logger.info(
+ f"[manage_task_list] Invoked. Email: '{user_google_email}', Action: '{action}'"
+ )
+
+ valid_actions = ("create", "update", "delete", "clear_completed")
+ if action not in valid_actions:
+ raise UserInputError(
+ f"Invalid action '{action}'. Must be one of: {', '.join(valid_actions)}"
)
- response = f"Task list {task_list_id} has been deleted for {user_google_email}. All tasks in this list have also been deleted."
+ if action == "create":
+ if not title:
+ raise UserInputError("'title' is required for the 'create' action.")
+ return await _create_task_list_impl(service, user_google_email, title)
- logger.info(f"Deleted task list {task_list_id} for {user_google_email}")
- return response
+ if action == "update":
+ if not task_list_id:
+ raise UserInputError("'task_list_id' is required for the 'update' action.")
+ if not title:
+ raise UserInputError("'title' is required for the 'update' action.")
+ return await _update_task_list_impl(
+ service, user_google_email, task_list_id, title
+ )
- except HttpError as error:
- message = _format_reauth_message(error, user_google_email)
- logger.error(message, exc_info=True)
- raise Exception(message)
- except Exception as e:
- message = f"Unexpected error: {e}."
- logger.exception(message)
- raise Exception(message)
+ if action == "delete":
+ if not task_list_id:
+ raise UserInputError("'task_list_id' is required for the 'delete' action.")
+ return await _delete_task_list_impl(service, user_google_email, task_list_id)
+
+ # action == "clear_completed"
+ if not task_list_id:
+ raise UserInputError(
+ "'task_list_id' is required for the 'clear_completed' action."
+ )
+ return await _clear_completed_tasks_impl(service, user_google_email, task_list_id)
+
+
+# --- Task tools ---
@server.tool() # type: ignore
@@ -633,10 +650,10 @@ async def get_task(
raise Exception(message)
-@server.tool() # type: ignore
-@require_google_service("tasks", "tasks") # type: ignore
-@handle_http_errors("create_task", service_type="tasks") # type: ignore
-async def create_task(
+# --- Task _impl functions ---
+
+
+async def _create_task_impl(
service: Resource,
user_google_email: str,
task_list_id: str,
@@ -646,72 +663,45 @@ async def create_task(
parent: Optional[str] = None,
previous: Optional[str] = None,
) -> str:
- """
- Create a new task in a task list.
-
- Args:
- user_google_email (str): The user's Google email address. Required.
- task_list_id (str): The ID of the task list to create the task in.
- title (str): The title of the task.
- notes (Optional[str]): Notes/description for the task.
- due (Optional[str]): Due date in RFC 3339 format (e.g., "2024-12-31T23:59:59Z").
- parent (Optional[str]): Parent task ID (for subtasks).
- previous (Optional[str]): Previous sibling task ID (for positioning).
-
- Returns:
- str: Confirmation message with the new task ID and details.
- """
+ """Implementation for creating a new task in a task list."""
logger.info(
f"[create_task] Invoked. Email: '{user_google_email}', Task List ID: {task_list_id}, Title: '{title}'"
)
- try:
- body = {"title": title}
- if notes:
- body["notes"] = notes
- if due:
- body["due"] = due
+ body = {"title": title}
+ if notes:
+ body["notes"] = notes
+ if due:
+ body["due"] = due
- params = {"tasklist": task_list_id, "body": body}
- if parent:
- params["parent"] = parent
- if previous:
- params["previous"] = previous
+ params = {"tasklist": task_list_id, "body": body}
+ if parent:
+ params["parent"] = parent
+ if previous:
+ params["previous"] = previous
- result = await asyncio.to_thread(service.tasks().insert(**params).execute)
+ result = await asyncio.to_thread(service.tasks().insert(**params).execute)
- response = f"""Task Created for {user_google_email}:
+ response = f"""Task Created for {user_google_email}:
- Title: {result["title"]}
- ID: {result["id"]}
- Status: {result.get("status", "N/A")}
- Updated: {result.get("updated", "N/A")}"""
- if result.get("due"):
- response += f"\n- Due Date: {result['due']}"
- if result.get("notes"):
- response += f"\n- Notes: {result['notes']}"
- if result.get("webViewLink"):
- response += f"\n- Web View Link: {result['webViewLink']}"
+ if result.get("due"):
+ response += f"\n- Due Date: {result['due']}"
+ if result.get("notes"):
+ response += f"\n- Notes: {result['notes']}"
+ if result.get("webViewLink"):
+ response += f"\n- Web View Link: {result['webViewLink']}"
- logger.info(
- f"Created task '{title}' with ID {result['id']} for {user_google_email}"
- )
- return response
-
- except HttpError as error:
- message = _format_reauth_message(error, user_google_email)
- logger.error(message, exc_info=True)
- raise Exception(message)
- except Exception as e:
- message = f"Unexpected error: {e}."
- logger.exception(message)
- raise Exception(message)
+ logger.info(
+ f"Created task '{title}' with ID {result['id']} for {user_google_email}"
+ )
+ return response
-@server.tool() # type: ignore
-@require_google_service("tasks", "tasks") # type: ignore
-@handle_http_errors("update_task", service_type="tasks") # type: ignore
-async def update_task(
+async def _update_task_impl(
service: Resource,
user_google_email: str,
task_list_id: str,
@@ -721,126 +711,74 @@ async def update_task(
status: Optional[str] = None,
due: Optional[str] = None,
) -> str:
- """
- Update an existing task.
-
- Args:
- user_google_email (str): The user's Google email address. Required.
- task_list_id (str): The ID of the task list containing the task.
- task_id (str): The ID of the task to update.
- title (Optional[str]): New title for the task.
- notes (Optional[str]): New notes/description for the task.
- status (Optional[str]): New status ("needsAction" or "completed").
- due (Optional[str]): New due date in RFC 3339 format.
-
- Returns:
- str: Confirmation message with updated task details.
- """
+ """Implementation for updating an existing task."""
logger.info(
f"[update_task] Invoked. Email: '{user_google_email}', Task List ID: {task_list_id}, Task ID: {task_id}"
)
- try:
- # First get the current task to build the update body
- current_task = await asyncio.to_thread(
- service.tasks().get(tasklist=task_list_id, task=task_id).execute
- )
+ # First get the current task to build the update body
+ current_task = await asyncio.to_thread(
+ service.tasks().get(tasklist=task_list_id, task=task_id).execute
+ )
- body = {
- "id": task_id,
- "title": title if title is not None else current_task.get("title", ""),
- "status": status
- if status is not None
- else current_task.get("status", "needsAction"),
- }
+ body = {
+ "id": task_id,
+ "title": title if title is not None else current_task.get("title", ""),
+ "status": status
+ if status is not None
+ else current_task.get("status", "needsAction"),
+ }
- if notes is not None:
- body["notes"] = notes
- elif current_task.get("notes"):
- body["notes"] = current_task["notes"]
+ if notes is not None:
+ body["notes"] = notes
+ elif current_task.get("notes"):
+ body["notes"] = current_task["notes"]
- if due is not None:
- body["due"] = due
- elif current_task.get("due"):
- body["due"] = current_task["due"]
+ if due is not None:
+ body["due"] = due
+ elif current_task.get("due"):
+ body["due"] = current_task["due"]
- result = await asyncio.to_thread(
- service.tasks()
- .update(tasklist=task_list_id, task=task_id, body=body)
- .execute
- )
+ result = await asyncio.to_thread(
+ service.tasks().update(tasklist=task_list_id, task=task_id, body=body).execute
+ )
- response = f"""Task Updated for {user_google_email}:
+ response = f"""Task Updated for {user_google_email}:
- Title: {result["title"]}
- ID: {result["id"]}
- Status: {result.get("status", "N/A")}
- Updated: {result.get("updated", "N/A")}"""
- if result.get("due"):
- response += f"\n- Due Date: {result['due']}"
- if result.get("notes"):
- response += f"\n- Notes: {result['notes']}"
- if result.get("completed"):
- response += f"\n- Completed: {result['completed']}"
+ if result.get("due"):
+ response += f"\n- Due Date: {result['due']}"
+ if result.get("notes"):
+ response += f"\n- Notes: {result['notes']}"
+ if result.get("completed"):
+ response += f"\n- Completed: {result['completed']}"
- logger.info(f"Updated task {task_id} for {user_google_email}")
- return response
-
- except HttpError as error:
- message = _format_reauth_message(error, user_google_email)
- logger.error(message, exc_info=True)
- raise Exception(message)
- except Exception as e:
- message = f"Unexpected error: {e}."
- logger.exception(message)
- raise Exception(message)
+ logger.info(f"Updated task {task_id} for {user_google_email}")
+ return response
-@server.tool() # type: ignore
-@require_google_service("tasks", "tasks") # type: ignore
-@handle_http_errors("delete_task", service_type="tasks") # type: ignore
-async def delete_task(
+async def _delete_task_impl(
service: Resource, user_google_email: str, task_list_id: str, task_id: str
) -> str:
- """
- Delete a task from a task list.
-
- Args:
- user_google_email (str): The user's Google email address. Required.
- task_list_id (str): The ID of the task list containing the task.
- task_id (str): The ID of the task to delete.
-
- Returns:
- str: Confirmation message.
- """
+ """Implementation for deleting a task from a task list."""
logger.info(
f"[delete_task] Invoked. Email: '{user_google_email}', Task List ID: {task_list_id}, Task ID: {task_id}"
)
- try:
- await asyncio.to_thread(
- service.tasks().delete(tasklist=task_list_id, task=task_id).execute
- )
+ await asyncio.to_thread(
+ service.tasks().delete(tasklist=task_list_id, task=task_id).execute
+ )
- response = f"Task {task_id} has been deleted from task list {task_list_id} for {user_google_email}."
+ response = f"Task {task_id} has been deleted from task list {task_list_id} for {user_google_email}."
- logger.info(f"Deleted task {task_id} for {user_google_email}")
- return response
-
- except HttpError as error:
- message = _format_reauth_message(error, user_google_email)
- logger.error(message, exc_info=True)
- raise Exception(message)
- except Exception as e:
- message = f"Unexpected error: {e}."
- logger.exception(message)
- raise Exception(message)
+ logger.info(f"Deleted task {task_id} for {user_google_email}")
+ return response
-@server.tool() # type: ignore
-@require_google_service("tasks", "tasks") # type: ignore
-@handle_http_errors("move_task", service_type="tasks") # type: ignore
-async def move_task(
+async def _move_task_impl(
service: Resource,
user_google_email: str,
task_list_id: str,
@@ -849,105 +787,148 @@ async def move_task(
previous: Optional[str] = None,
destination_task_list: Optional[str] = None,
) -> str:
- """
- Move a task to a different position or parent within the same list, or to a different list.
-
- Args:
- user_google_email (str): The user's Google email address. Required.
- task_list_id (str): The ID of the current task list containing the task.
- task_id (str): The ID of the task to move.
- parent (Optional[str]): New parent task ID (for making it a subtask).
- previous (Optional[str]): Previous sibling task ID (for positioning).
- destination_task_list (Optional[str]): Destination task list ID (for moving between lists).
-
- Returns:
- str: Confirmation message with updated task details.
- """
+ """Implementation for moving a task to a different position, parent, or list."""
logger.info(
f"[move_task] Invoked. Email: '{user_google_email}', Task List ID: {task_list_id}, Task ID: {task_id}"
)
- try:
- params = {"tasklist": task_list_id, "task": task_id}
- if parent:
- params["parent"] = parent
- if previous:
- params["previous"] = previous
- if destination_task_list:
- params["destinationTasklist"] = destination_task_list
+ params = {"tasklist": task_list_id, "task": task_id}
+ if parent:
+ params["parent"] = parent
+ if previous:
+ params["previous"] = previous
+ if destination_task_list:
+ params["destinationTasklist"] = destination_task_list
- result = await asyncio.to_thread(service.tasks().move(**params).execute)
+ result = await asyncio.to_thread(service.tasks().move(**params).execute)
- response = f"""Task Moved for {user_google_email}:
+ response = f"""Task Moved for {user_google_email}:
- Title: {result["title"]}
- ID: {result["id"]}
- Status: {result.get("status", "N/A")}
- Updated: {result.get("updated", "N/A")}"""
- if result.get("parent"):
- response += f"\n- Parent Task ID: {result['parent']}"
- if result.get("position"):
- response += f"\n- Position: {result['position']}"
+ if result.get("parent"):
+ response += f"\n- Parent Task ID: {result['parent']}"
+ if result.get("position"):
+ response += f"\n- Position: {result['position']}"
- move_details = []
- if destination_task_list:
- move_details.append(f"moved to task list {destination_task_list}")
- if parent:
- move_details.append(f"made a subtask of {parent}")
- if previous:
- move_details.append(f"positioned after {previous}")
+ move_details = []
+ if destination_task_list:
+ move_details.append(f"moved to task list {destination_task_list}")
+ if parent:
+ move_details.append(f"made a subtask of {parent}")
+ if previous:
+ move_details.append(f"positioned after {previous}")
- if move_details:
- response += f"\n- Move Details: {', '.join(move_details)}"
+ if move_details:
+ response += f"\n- Move Details: {', '.join(move_details)}"
- logger.info(f"Moved task {task_id} for {user_google_email}")
- return response
+ logger.info(f"Moved task {task_id} for {user_google_email}")
+ return response
- except HttpError as error:
- message = _format_reauth_message(error, user_google_email)
- logger.error(message, exc_info=True)
- raise Exception(message)
- except Exception as e:
- message = f"Unexpected error: {e}."
- logger.exception(message)
- raise Exception(message)
+
+# --- Consolidated manage_task tool ---
@server.tool() # type: ignore
@require_google_service("tasks", "tasks") # type: ignore
-@handle_http_errors("clear_completed_tasks", service_type="tasks") # type: ignore
-async def clear_completed_tasks(
- service: Resource, user_google_email: str, task_list_id: str
+@handle_http_errors("manage_task", service_type="tasks") # type: ignore
+async def manage_task(
+ service: Resource,
+ user_google_email: str,
+ action: str,
+ task_list_id: str,
+ task_id: Optional[str] = None,
+ title: Optional[str] = None,
+ notes: Optional[str] = None,
+ status: Optional[str] = None,
+ due: Optional[str] = None,
+ parent: Optional[str] = None,
+ previous: Optional[str] = None,
+ destination_task_list: Optional[str] = None,
) -> str:
"""
- Clear all completed tasks from a task list. The tasks will be marked as hidden.
+ Manage tasks: create, update, delete, or move tasks within task lists.
Args:
user_google_email (str): The user's Google email address. Required.
- task_list_id (str): The ID of the task list to clear completed tasks from.
+ action (str): The action to perform. Must be one of: "create", "update", "delete", "move".
+ task_list_id (str): The ID of the task list. Required for all actions.
+ task_id (Optional[str]): The ID of the task. Required for "update", "delete", and "move" actions.
+ title (Optional[str]): The title of the task. Required for "create", optional for "update".
+ notes (Optional[str]): Notes/description for the task. Used by "create" and "update" actions.
+ status (Optional[str]): Task status ("needsAction" or "completed"). Used by "update" action.
+ due (Optional[str]): Due date in RFC 3339 format (e.g., "2024-12-31T23:59:59Z"). Used by "create" and "update" actions.
+ parent (Optional[str]): Parent task ID (for subtasks). Used by "create" and "move" actions.
+ previous (Optional[str]): Previous sibling task ID (for positioning). Used by "create" and "move" actions.
+ destination_task_list (Optional[str]): Destination task list ID (for moving between lists). Used by "move" action.
Returns:
- str: Confirmation message.
+ str: Result of the requested action.
"""
logger.info(
- f"[clear_completed_tasks] Invoked. Email: '{user_google_email}', Task List ID: {task_list_id}"
+ f"[manage_task] Invoked. Email: '{user_google_email}', Action: '{action}', Task List ID: {task_list_id}"
)
- try:
- await asyncio.to_thread(service.tasks().clear(tasklist=task_list_id).execute)
+ allowed_statuses = {"needsAction", "completed"}
+ if status is not None and status not in allowed_statuses:
+ raise UserInputError("invalid status: must be 'needsAction' or 'completed'")
- response = f"All completed tasks have been cleared from task list {task_list_id} for {user_google_email}. The tasks are now hidden and won't appear in default task list views."
-
- logger.info(
- f"Cleared completed tasks from list {task_list_id} for {user_google_email}"
+ valid_actions = ("create", "update", "delete", "move")
+ if action not in valid_actions:
+ raise UserInputError(
+ f"Invalid action '{action}'. Must be one of: {', '.join(valid_actions)}"
)
- return response
- except HttpError as error:
- message = _format_reauth_message(error, user_google_email)
- logger.error(message, exc_info=True)
- raise Exception(message)
- except Exception as e:
- message = f"Unexpected error: {e}."
- logger.exception(message)
- raise Exception(message)
+ if action == "create":
+ if status is not None:
+ raise UserInputError("'status' is only supported for the 'update' action.")
+ if not title:
+ raise UserInputError("'title' is required for the 'create' action.")
+ return await _create_task_impl(
+ service,
+ user_google_email,
+ task_list_id,
+ title,
+ notes=notes,
+ due=due,
+ parent=parent,
+ previous=previous,
+ )
+
+ if action == "update":
+ if status is not None and status not in allowed_statuses:
+ raise UserInputError("invalid status: must be 'needsAction' or 'completed'")
+ if not task_id:
+ raise UserInputError("'task_id' is required for the 'update' action.")
+ return await _update_task_impl(
+ service,
+ user_google_email,
+ task_list_id,
+ task_id,
+ title=title,
+ notes=notes,
+ status=status,
+ due=due,
+ )
+
+ if action == "delete":
+ if not task_id:
+ raise UserInputError("'task_id' is required for the 'delete' action.")
+ return await _delete_task_impl(
+ service, user_google_email, task_list_id, task_id
+ )
+
+ # action == "move"
+ if not task_id:
+ raise UserInputError("'task_id' is required for the 'move' action.")
+ return await _move_task_impl(
+ service,
+ user_google_email,
+ task_list_id,
+ task_id,
+ parent=parent,
+ previous=previous,
+ destination_task_list=destination_task_list,
+ )
diff --git a/manifest.json b/manifest.json
index 815f9c1..a5c02dc 100644
--- a/manifest.json
+++ b/manifest.json
@@ -2,7 +2,7 @@
"dxt_version": "0.1",
"name": "workspace-mcp",
"display_name": "Google Workspace MCP",
- "version": "1.13.1",
+ "version": "1.14.0",
"description": "Full natural language control over Google Calendar, Drive, Gmail, Docs, Sheets, Slides, Forms, Tasks, Chat and Custom Search through all MCP clients, AI assistants and developer tools",
"long_description": "A production-ready MCP server that integrates all major Google Workspace services with AI assistants. Includes Google PSE integration for custom web searches.",
"author": {
diff --git a/pyproject.toml b/pyproject.toml
index 708851e..2b3b828 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "workspace-mcp"
-version = "1.13.1"
+version = "1.14.0"
description = "Comprehensive, highly performant Google Workspace Streamable HTTP & SSE MCP Server for Calendar, Gmail, Docs, Sheets, Slides & Drive"
readme = "README.md"
keywords = [ "mcp", "google", "workspace", "llm", "ai", "claude", "model", "context", "protocol", "server"]
@@ -22,6 +22,7 @@ dependencies = [
"python-dotenv>=1.1.0",
"pyyaml>=6.0.2",
"cryptography>=45.0.0",
+ "defusedxml>=0.7.1",
]
classifiers = [
"Development Status :: 4 - Beta",
diff --git a/server.json b/server.json
index e51d3d7..4e2353d 100644
--- a/server.json
+++ b/server.json
@@ -3,7 +3,7 @@
"name": "io.github.taylorwilsdon/workspace-mcp",
"description": "Google Workspace MCP server for Gmail, Drive, Calendar, Docs, Sheets, Slides, Forms, Tasks, Chat.",
"status": "active",
- "version": "1.13.1",
+ "version": "1.14.0",
"packages": [
{
"registryType": "pypi",
@@ -11,7 +11,7 @@
"transport": {
"type": "stdio"
},
- "version": "1.13.1"
+ "version": "1.14.0"
}
]
}
diff --git a/tests/auth/test_google_auth_pkce.py b/tests/auth/test_google_auth_pkce.py
new file mode 100644
index 0000000..57e775e
--- /dev/null
+++ b/tests/auth/test_google_auth_pkce.py
@@ -0,0 +1,118 @@
+"""Regression tests for OAuth PKCE flow wiring."""
+
+import os
+import sys
+from unittest.mock import patch
+
+
+sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../..")))
+
+from auth.google_auth import create_oauth_flow # noqa: E402
+
+
+DUMMY_CLIENT_CONFIG = {
+ "web": {
+ "client_id": "dummy-client-id.apps.googleusercontent.com",
+ "client_secret": "dummy-secret",
+ "auth_uri": "https://accounts.google.com/o/oauth2/auth",
+ "token_uri": "https://oauth2.googleapis.com/token",
+ }
+}
+
+
+def test_create_oauth_flow_autogenerates_verifier_when_missing():
+ expected_flow = object()
+ with (
+ patch(
+ "auth.google_auth.load_client_secrets_from_env",
+ return_value=DUMMY_CLIENT_CONFIG,
+ ),
+ patch(
+ "auth.google_auth.Flow.from_client_config",
+ return_value=expected_flow,
+ ) as mock_from_client_config,
+ ):
+ flow = create_oauth_flow(
+ scopes=["openid"],
+ redirect_uri="http://localhost/callback",
+ state="oauth-state-1",
+ )
+
+ assert flow is expected_flow
+ args, kwargs = mock_from_client_config.call_args
+ assert args[0] == DUMMY_CLIENT_CONFIG
+ assert kwargs["autogenerate_code_verifier"] is True
+ assert "code_verifier" not in kwargs
+
+
+def test_create_oauth_flow_preserves_callback_verifier():
+ expected_flow = object()
+ with (
+ patch(
+ "auth.google_auth.load_client_secrets_from_env",
+ return_value=DUMMY_CLIENT_CONFIG,
+ ),
+ patch(
+ "auth.google_auth.Flow.from_client_config",
+ return_value=expected_flow,
+ ) as mock_from_client_config,
+ ):
+ flow = create_oauth_flow(
+ scopes=["openid"],
+ redirect_uri="http://localhost/callback",
+ state="oauth-state-2",
+ code_verifier="saved-verifier",
+ )
+
+ assert flow is expected_flow
+ args, kwargs = mock_from_client_config.call_args
+ assert args[0] == DUMMY_CLIENT_CONFIG
+ assert kwargs["code_verifier"] == "saved-verifier"
+ assert kwargs["autogenerate_code_verifier"] is False
+
+
+def test_create_oauth_flow_file_config_still_enables_pkce():
+ expected_flow = object()
+ with (
+ patch("auth.google_auth.load_client_secrets_from_env", return_value=None),
+ patch("auth.google_auth.os.path.exists", return_value=True),
+ patch(
+ "auth.google_auth.Flow.from_client_secrets_file",
+ return_value=expected_flow,
+ ) as mock_from_file,
+ ):
+ flow = create_oauth_flow(
+ scopes=["openid"],
+ redirect_uri="http://localhost/callback",
+ state="oauth-state-3",
+ )
+
+ assert flow is expected_flow
+ _args, kwargs = mock_from_file.call_args
+ assert kwargs["autogenerate_code_verifier"] is True
+ assert "code_verifier" not in kwargs
+
+
+def test_create_oauth_flow_allows_disabling_autogenerate_without_verifier():
+ expected_flow = object()
+ with (
+ patch(
+ "auth.google_auth.load_client_secrets_from_env",
+ return_value=DUMMY_CLIENT_CONFIG,
+ ),
+ patch(
+ "auth.google_auth.Flow.from_client_config",
+ return_value=expected_flow,
+ ) as mock_from_client_config,
+ ):
+ flow = create_oauth_flow(
+ scopes=["openid"],
+ redirect_uri="http://localhost/callback",
+ state="oauth-state-4",
+ autogenerate_code_verifier=False,
+ )
+
+ assert flow is expected_flow
+ _args, kwargs = mock_from_client_config.call_args
+ assert kwargs["autogenerate_code_verifier"] is False
+ assert "code_verifier" not in kwargs
diff --git a/tests/gcontacts/test_contacts_tools.py b/tests/gcontacts/test_contacts_tools.py
index b82614b..a3e9a8b 100644
--- a/tests/gcontacts/test_contacts_tools.py
+++ b/tests/gcontacts/test_contacts_tools.py
@@ -291,9 +291,7 @@ class TestImports:
assert hasattr(contacts_tools, "list_contacts")
assert hasattr(contacts_tools, "get_contact")
assert hasattr(contacts_tools, "search_contacts")
- assert hasattr(contacts_tools, "create_contact")
- assert hasattr(contacts_tools, "update_contact")
- assert hasattr(contacts_tools, "delete_contact")
+ assert hasattr(contacts_tools, "manage_contact")
def test_import_group_tools(self):
"""Test that group tools can be imported."""
@@ -301,18 +299,13 @@ class TestImports:
assert hasattr(contacts_tools, "list_contact_groups")
assert hasattr(contacts_tools, "get_contact_group")
- assert hasattr(contacts_tools, "create_contact_group")
- assert hasattr(contacts_tools, "update_contact_group")
- assert hasattr(contacts_tools, "delete_contact_group")
- assert hasattr(contacts_tools, "modify_contact_group_members")
+ assert hasattr(contacts_tools, "manage_contact_group")
def test_import_batch_tools(self):
"""Test that batch tools can be imported."""
from gcontacts import contacts_tools
- assert hasattr(contacts_tools, "batch_create_contacts")
- assert hasattr(contacts_tools, "batch_update_contacts")
- assert hasattr(contacts_tools, "batch_delete_contacts")
+ assert hasattr(contacts_tools, "manage_contacts_batch")
class TestConstants:
diff --git a/tests/gdocs/test_docs_markdown.py b/tests/gdocs/test_docs_markdown.py
index fceabfe..804c390 100644
--- a/tests/gdocs/test_docs_markdown.py
+++ b/tests/gdocs/test_docs_markdown.py
@@ -270,6 +270,69 @@ class TestLists:
assert "- Item two" in md
+CHECKLIST_DOC = {
+ "title": "Checklist Test",
+ "lists": {
+ "kix.checklist001": {
+ "listProperties": {
+ "nestingLevels": [
+ {"glyphType": "GLYPH_TYPE_UNSPECIFIED"},
+ ]
+ }
+ }
+ },
+ "body": {
+ "content": [
+ {"sectionBreak": {"sectionStyle": {}}},
+ {
+ "paragraph": {
+ "elements": [
+ {"textRun": {"content": "Buy groceries\n", "textStyle": {}}}
+ ],
+ "paragraphStyle": {"namedStyleType": "NORMAL_TEXT"},
+ "bullet": {"listId": "kix.checklist001", "nestingLevel": 0},
+ }
+ },
+ {
+ "paragraph": {
+ "elements": [
+ {
+ "textRun": {
+ "content": "Walk the dog\n",
+ "textStyle": {"strikethrough": True},
+ }
+ }
+ ],
+ "paragraphStyle": {"namedStyleType": "NORMAL_TEXT"},
+ "bullet": {"listId": "kix.checklist001", "nestingLevel": 0},
+ }
+ },
+ ]
+ },
+}
+
+
+class TestChecklists:
+ def test_unchecked(self):
+ md = convert_doc_to_markdown(CHECKLIST_DOC)
+ assert "- [ ] Buy groceries" in md
+
+ def test_checked(self):
+ md = convert_doc_to_markdown(CHECKLIST_DOC)
+ assert "- [x] Walk the dog" in md
+
+ def test_checked_no_strikethrough(self):
+ """Checked items should not have redundant ~~strikethrough~~ markdown."""
+ md = convert_doc_to_markdown(CHECKLIST_DOC)
+ assert "~~Walk the dog~~" not in md
+
+ def test_regular_bullet_not_checklist(self):
+ """Bullet lists with glyphSymbol should remain as plain bullets."""
+ md = convert_doc_to_markdown(LIST_DOC)
+ assert "[ ]" not in md
+ assert "[x]" not in md
+
+
class TestEmptyDoc:
def test_empty(self):
md = convert_doc_to_markdown({"title": "Empty", "body": {"content": []}})
diff --git a/uv.lock b/uv.lock
index 7842e03..859431b 100644
--- a/uv.lock
+++ b/uv.lock
@@ -404,6 +404,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/1c/7c/996760c30f1302704af57c66ff2d723f7d656d0d0b93563b5528a51484bb/cyclopts-4.5.1-py3-none-any.whl", hash = "sha256:0642c93601e554ca6b7b9abd81093847ea4448b2616280f2a0952416574e8c7a", size = 199807 },
]
+[[package]]
+name = "defusedxml"
+version = "0.7.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/0f/d5/c66da9b79e5bdb124974bfe172b4daf3c984ebd9c2a06e2b8a4dc7331c72/defusedxml-0.7.1.tar.gz", hash = "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69", size = 75520 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61", size = 25604 },
+]
+
[[package]]
name = "dnspython"
version = "2.8.0"
@@ -2035,10 +2044,11 @@ wheels = [
[[package]]
name = "workspace-mcp"
-version = "1.13.0"
+version = "1.13.1"
source = { editable = "." }
dependencies = [
{ name = "cryptography" },
+ { name = "defusedxml" },
{ name = "fastapi" },
{ name = "fastmcp" },
{ name = "google-api-python-client" },
@@ -2098,6 +2108,7 @@ valkey = [
[package.metadata]
requires-dist = [
{ name = "cryptography", specifier = ">=45.0.0" },
+ { name = "defusedxml", specifier = ">=0.7.1" },
{ name = "fastapi", specifier = ">=0.115.12" },
{ name = "fastmcp", specifier = ">=3.0.2" },
{ name = "google-api-python-client", specifier = ">=2.168.0" },